diff --git a/.cursor/rules/http-mvc.mdc b/.cursor/rules/http-mvc.mdc
new file mode 100644
index 0000000..9d83ec8
--- /dev/null
+++ b/.cursor/rules/http-mvc.mdc
@@ -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`.
diff --git a/.cursor/rules/message-deliver.mdc b/.cursor/rules/message-deliver.mdc
index a791db3..3c6ea7c 100644
--- a/.cursor/rules/message-deliver.mdc
+++ b/.cursor/rules/message-deliver.mdc
@@ -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.
diff --git a/.cursor/rules/module-layers.mdc b/.cursor/rules/module-layers.mdc
new file mode 100644
index 0000000..4a8487d
--- /dev/null
+++ b/.cursor/rules/module-layers.mdc
@@ -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.
diff --git a/.cursor/skills/spark-im-v3/SKILL.md b/.cursor/skills/spark-im-v3/SKILL.md
index e6e4003..0ba9a04 100644
--- a/.cursor/skills/spark-im-v3/SKILL.md
+++ b/.cursor/skills/spark-im-v3/SKILL.md
@@ -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`
diff --git a/.cursor/skills/spark-im-v3/http.md b/.cursor/skills/spark-im-v3/http.md
new file mode 100644
index 0000000..818fe0d
--- /dev/null
+++ b/.cursor/skills/spark-im-v3/http.md
@@ -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`.
diff --git a/.cursor/skills/spark-im-v3/i18n.md b/.cursor/skills/spark-im-v3/i18n.md
new file mode 100644
index 0000000..dc6a169
--- /dev/null
+++ b/.cursor/skills/spark-im-v3/i18n.md
@@ -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.
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..d73ac35
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,16 @@
+.git
+.cursor
+.idea
+.vscode
+bin
+logs
+vendor
+examples
+docs
+*.md
+*.test
+*.out
+coverage.out
+.env
+certs
+deploy/nginx
diff --git a/.gitignore b/.gitignore
index 713b24b..cdf32ed 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
bin/
+logs/
vendor/
*.exe
*.test
diff --git a/.golangci.yaml b/.golangci.yaml
index 47f9275..90ed6aa 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -5,7 +5,7 @@ run:
tests: true
go: "1.26"
-linters-settings:
+linters_settings:
funlen:
lines: 100
statements: 100
diff --git a/AGENTS.md b/AGENTS.md
index a33e3b4..259c598 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..85d62f3
--- /dev/null
+++ b/Dockerfile
@@ -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"]
diff --git a/Makefile b/Makefile
index a47d11d..b4bd077 100644
--- a/Makefile
+++ b/Makefile
@@ -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
diff --git a/README.md b/README.md
index dc655f8..b66f76b 100644
--- a/README.md
+++ b/README.md
@@ -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`.
diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go
new file mode 100644
index 0000000..95e0dd7
--- /dev/null
+++ b/cmd/migrate/main.go
@@ -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
+ }
+}
diff --git a/configs/config.yaml b/configs/config.yaml
index 66e4730..5336420 100644
--- a/configs/config.yaml
+++ b/configs/config.yaml
@@ -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
diff --git a/deploy/docker/config.docker.yaml b/deploy/docker/config.docker.yaml
new file mode 100644
index 0000000..b4bf1a3
--- /dev/null
+++ b/deploy/docker/config.docker.yaml
@@ -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
diff --git a/deploy/docker/deploy.sh b/deploy/docker/deploy.sh
new file mode 100755
index 0000000..f6efda2
--- /dev/null
+++ b/deploy/docker/deploy.sh
@@ -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"
diff --git a/deploy/docker/docker-compose.yaml b/deploy/docker/docker-compose.yaml
new file mode 100644
index 0000000..a58be45
--- /dev/null
+++ b/deploy/docker/docker-compose.yaml
@@ -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
diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md
new file mode 100644
index 0000000..e5658e3
--- /dev/null
+++ b/deploy/nginx/README.md
@@ -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`.
diff --git a/deploy/nginx/host/spark-acme.conf b/deploy/nginx/host/spark-acme.conf
new file mode 100644
index 0000000..c33e359
--- /dev/null
+++ b/deploy/nginx/host/spark-acme.conf
@@ -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;
+ }
+}
diff --git a/deploy/nginx/host/spark-sni.conf b/deploy/nginx/host/spark-sni.conf
new file mode 100644
index 0000000..feeff37
--- /dev/null
+++ b/deploy/nginx/host/spark-sni.conf
@@ -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;
+}
diff --git a/deploy/nginx/host/sparkapi.conf b/deploy/nginx/host/sparkapi.conf
new file mode 100644
index 0000000..ccc37f4
--- /dev/null
+++ b/deploy/nginx/host/sparkapi.conf
@@ -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;
+ }
+}
diff --git a/deploy/nginx/http.conf b/deploy/nginx/http.conf
index 39a696f..1f70083 100644
--- a/deploy/nginx/http.conf
+++ b/deploy/nginx/http.conf
@@ -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;
diff --git a/deploy/nginx/install-host.sh b/deploy/nginx/install-host.sh
new file mode 100755
index 0000000..e4eef41
--- /dev/null
+++ b/deploy/nginx/install-host.sh
@@ -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"
diff --git a/deploy/nginx/nginx.conf b/deploy/nginx/nginx.conf
new file mode 100644
index 0000000..52366a6
--- /dev/null
+++ b/deploy/nginx/nginx.conf
@@ -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;
diff --git a/deploy/nginx/stream.conf b/deploy/nginx/stream.conf
index 700120c..f41aab2 100644
--- a/deploy/nginx/stream.conf
+++ b/deploy/nginx/stream.conf
@@ -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;
}
}
diff --git a/docs/architecture-v3.md b/docs/architecture-v3.md
index 73811cc..945c582 100644
--- a/docs/architecture-v3.md
+++ b/docs/architecture-v3.md
@@ -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`。
---
diff --git a/examples/android/.gitignore b/examples/android/.gitignore
new file mode 100644
index 0000000..a22daf1
--- /dev/null
+++ b/examples/android/.gitignore
@@ -0,0 +1,5 @@
+.gradle/
+**/build/
+local.properties
+*.apk
+.idea/
diff --git a/examples/android/app/build.gradle.kts b/examples/android/app/build.gradle.kts
new file mode 100644
index 0000000..6239952
--- /dev/null
+++ b/examples/android/app/build.gradle.kts
@@ -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"
+ }
+}
diff --git a/examples/android/app/src/main/AndroidManifest.xml b/examples/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..1cfeedb
--- /dev/null
+++ b/examples/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/android/app/src/main/java/com/spark/check/CheckActivity.kt b/examples/android/app/src/main/java/com/spark/check/CheckActivity.kt
new file mode 100644
index 0000000..3c1b3f7
--- /dev/null
+++ b/examples/android/app/src/main/java/com/spark/check/CheckActivity.kt
@@ -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()
+}
diff --git a/examples/android/app/src/main/java/com/spark/check/SparkClient.kt b/examples/android/app/src/main/java/com/spark/check/SparkClient.kt
new file mode 100644
index 0000000..7b726a2
--- /dev/null
+++ b/examples/android/app/src/main/java/com/spark/check/SparkClient.kt
@@ -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)
+ }
+ }
+}
diff --git a/examples/android/app/src/main/res/drawable/ic_launcher.xml b/examples/android/app/src/main/res/drawable/ic_launcher.xml
new file mode 100644
index 0000000..9ba4de0
--- /dev/null
+++ b/examples/android/app/src/main/res/drawable/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/examples/android/app/src/main/res/xml/network_security_config.xml b/examples/android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..d7b4192
--- /dev/null
+++ b/examples/android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/examples/android/build.gradle.kts b/examples/android/build.gradle.kts
new file mode 100644
index 0000000..c7ad754
--- /dev/null
+++ b/examples/android/build.gradle.kts
@@ -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
+}
diff --git a/examples/android/gradle.properties b/examples/android/gradle.properties
new file mode 100644
index 0000000..f0a2e55
--- /dev/null
+++ b/examples/android/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
diff --git a/examples/android/gradle/wrapper/gradle-wrapper.jar b/examples/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..2c35211
Binary files /dev/null and b/examples/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/examples/android/gradle/wrapper/gradle-wrapper.properties b/examples/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..a659fde
--- /dev/null
+++ b/examples/android/gradle/wrapper/gradle-wrapper.properties
@@ -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
diff --git a/examples/android/gradlew b/examples/android/gradlew
new file mode 100755
index 0000000..ef07e01
--- /dev/null
+++ b/examples/android/gradlew
@@ -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" "$@"
diff --git a/examples/android/gradlew.bat b/examples/android/gradlew.bat
new file mode 100644
index 0000000..5eed7ee
--- /dev/null
+++ b/examples/android/gradlew.bat
@@ -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
diff --git a/examples/android/settings.gradle.kts b/examples/android/settings.gradle.kts
new file mode 100644
index 0000000..3e62155
--- /dev/null
+++ b/examples/android/settings.gradle.kts
@@ -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")
diff --git a/go.mod b/go.mod
index 7afc08c..63acd0e 100644
--- a/go.mod
+++ b/go.mod
@@ -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
)
diff --git a/go.sum b/go.sum
index 85a3d86..892975a 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/internal/bootstrap/app.go b/internal/bootstrap/app.go
index 5cd944a..13e22b1 100644
--- a/internal/bootstrap/app.go
+++ b/internal/bootstrap/app.go
@@ -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
}
diff --git a/internal/bootstrap/config.go b/internal/bootstrap/config.go
index 3f78e8f..fe88b35 100644
--- a/internal/bootstrap/config.go
+++ b/internal/bootstrap/config.go
@@ -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
+}
diff --git a/internal/bootstrap/health.go b/internal/bootstrap/health.go
new file mode 100644
index 0000000..f7f7128
--- /dev/null
+++ b/internal/bootstrap/health.go
@@ -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
+}
diff --git a/internal/bootstrap/health_test.go b/internal/bootstrap/health_test.go
new file mode 100644
index 0000000..742c011
--- /dev/null
+++ b/internal/bootstrap/health_test.go
@@ -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)
+ }
+}
diff --git a/internal/infrastructure/id/id.go b/internal/infrastructure/id/id.go
index b229669..62003e8 100644
--- a/internal/infrastructure/id/id.go
+++ b/internal/infrastructure/id/id.go
@@ -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()
diff --git a/internal/infrastructure/logger/body.go b/internal/infrastructure/logger/body.go
new file mode 100644
index 0000000..988805f
--- /dev/null
+++ b/internal/infrastructure/logger/body.go
@@ -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] + "..."
+ }
+ return s
+}
diff --git a/internal/infrastructure/logger/logger.go b/internal/infrastructure/logger/logger.go
index 4428a03..cce1ceb 100644
--- a/internal/infrastructure/logger/logger.go
+++ b/internal/infrastructure/logger/logger.go
@@ -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...)
}
diff --git a/internal/infrastructure/logger/logger_test.go b/internal/infrastructure/logger/logger_test.go
new file mode 100644
index 0000000..2e366f1
--- /dev/null
+++ b/internal/infrastructure/logger/logger_test.go
@@ -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")
+ }
+}
diff --git a/internal/infrastructure/logger/options.go b/internal/infrastructure/logger/options.go
new file mode 100644
index 0000000..d7de1a3
--- /dev/null
+++ b/internal/infrastructure/logger/options.go
@@ -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,
+ }
+}
diff --git a/internal/infrastructure/mysql/gorm_logger.go b/internal/infrastructure/mysql/gorm_logger.go
new file mode 100644
index 0000000..520be9f
--- /dev/null
+++ b/internal/infrastructure/mysql/gorm_logger.go
@@ -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
+ }
+}
diff --git a/internal/infrastructure/mysql/gorm_logger_test.go b/internal/infrastructure/mysql/gorm_logger_test.go
new file mode 100644
index 0000000..bf60b86
--- /dev/null
+++ b/internal/infrastructure/mysql/gorm_logger_test.go
@@ -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())
+ }
+}
diff --git a/internal/infrastructure/mysql/mysql.go b/internal/infrastructure/mysql/mysql.go
index 01b0794..dcb7a84 100644
--- a/internal/infrastructure/mysql/mysql.go
+++ b/internal/infrastructure/mysql/mysql.go
@@ -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)
+}
diff --git a/internal/infrastructure/objectstore/ops.go b/internal/infrastructure/objectstore/ops.go
new file mode 100644
index 0000000..fc93ae6
--- /dev/null
+++ b/internal/infrastructure/objectstore/ops.go
@@ -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
+}
diff --git a/internal/infrastructure/objectstore/policy.go b/internal/infrastructure/objectstore/policy.go
new file mode 100644
index 0000000..3bfae9b
--- /dev/null
+++ b/internal/infrastructure/objectstore/policy.go
@@ -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))
+ }
+}
diff --git a/internal/infrastructure/objectstore/store.go b/internal/infrastructure/objectstore/store.go
new file mode 100644
index 0000000..31310d7
--- /dev/null
+++ b/internal/infrastructure/objectstore/store.go
@@ -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
+ }
+}
diff --git a/internal/infrastructure/objectstore/store_test.go b/internal/infrastructure/objectstore/store_test.go
new file mode 100644
index 0000000..0adbc78
--- /dev/null
+++ b/internal/infrastructure/objectstore/store_test.go
@@ -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)
+ }
+}
diff --git a/internal/infrastructure/push/push.go b/internal/infrastructure/push/push.go
index 86c2ae1..0ff2d1c 100644
--- a/internal/infrastructure/push/push.go
+++ b/internal/infrastructure/push/push.go
@@ -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
diff --git a/internal/infrastructure/redis/redis.go b/internal/infrastructure/redis/redis.go
index e69d067..3d575bd 100644
--- a/internal/infrastructure/redis/redis.go
+++ b/internal/infrastructure/redis/redis.go
@@ -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()
+}
diff --git a/internal/infrastructure/redis/redis_test.go b/internal/infrastructure/redis/redis_test.go
new file mode 100644
index 0000000..bb56df0
--- /dev/null
+++ b/internal/infrastructure/redis/redis_test.go
@@ -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())
+ }
+}
diff --git a/internal/module/auth/entity/principal.go b/internal/module/auth/entity/principal.go
new file mode 100644
index 0000000..ac0dc7c
--- /dev/null
+++ b/internal/module/auth/entity/principal.go
@@ -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"`
+}
diff --git a/internal/module/auth/logic/denylist.go b/internal/module/auth/logic/denylist.go
new file mode 100644
index 0000000..a751ede
--- /dev/null
+++ b/internal/module/auth/logic/denylist.go
@@ -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()
+}
diff --git a/internal/module/auth/logic/jwt.go b/internal/module/auth/logic/jwt.go
new file mode 100644
index 0000000..e8fd485
--- /dev/null
+++ b/internal/module/auth/logic/jwt.go
@@ -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
+}
diff --git a/internal/module/auth/logic/jwt_test.go b/internal/module/auth/logic/jwt_test.go
new file mode 100644
index 0000000..e4bf03b
--- /dev/null
+++ b/internal/module/auth/logic/jwt_test.go
@@ -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)
+ }
+}
diff --git a/internal/module/auth/logic/login.go b/internal/module/auth/logic/login.go
new file mode 100644
index 0000000..d88c720
--- /dev/null
+++ b/internal/module/auth/logic/login.go
@@ -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
+}
diff --git a/internal/module/auth/logic/login_test.go b/internal/module/auth/logic/login_test.go
new file mode 100644
index 0000000..7bfe8f0
--- /dev/null
+++ b/internal/module/auth/logic/login_test.go
@@ -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
+}
diff --git a/internal/module/auth/logic/refresh.go b/internal/module/auth/logic/refresh.go
new file mode 100644
index 0000000..59751dc
--- /dev/null
+++ b/internal/module/auth/logic/refresh.go
@@ -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()
+}
diff --git a/internal/module/auth/logic/static.go b/internal/module/auth/logic/static.go
new file mode 100644
index 0000000..48c3dda
--- /dev/null
+++ b/internal/module/auth/logic/static.go
@@ -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 }
diff --git a/internal/module/auth/service.go b/internal/module/auth/service.go
deleted file mode 100644
index a48d6c9..0000000
--- a/internal/module/auth/service.go
+++ /dev/null
@@ -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)
-}
diff --git a/internal/module/auth/static.go b/internal/module/auth/static.go
deleted file mode 100644
index c17cb93..0000000
--- a/internal/module/auth/static.go
+++ /dev/null
@@ -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
-}
diff --git a/internal/module/conversation/entity/conversation.go b/internal/module/conversation/entity/conversation.go
new file mode 100644
index 0000000..c1f0795
--- /dev/null
+++ b/internal/module/conversation/entity/conversation.go
@@ -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
+}
diff --git a/internal/module/conversation/entity/member.go b/internal/module/conversation/entity/member.go
new file mode 100644
index 0000000..2c4b963
--- /dev/null
+++ b/internal/module/conversation/entity/member.go
@@ -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
+}
diff --git a/internal/module/conversation/entity/p2p.go b/internal/module/conversation/entity/p2p.go
new file mode 100644
index 0000000..9aecc9e
--- /dev/null
+++ b/internal/module/conversation/entity/p2p.go
@@ -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
+}
diff --git a/internal/module/conversation/logic/conversation.go b/internal/module/conversation/logic/conversation.go
new file mode 100644
index 0000000..68ff999
--- /dev/null
+++ b/internal/module/conversation/logic/conversation.go
@@ -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
+}
diff --git a/internal/module/conversation/logic/p2p.go b/internal/module/conversation/logic/p2p.go
new file mode 100644
index 0000000..075f416
--- /dev/null
+++ b/internal/module/conversation/logic/p2p.go
@@ -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)
+}
diff --git a/internal/module/conversation/model/conversation.go b/internal/module/conversation/model/conversation.go
new file mode 100644
index 0000000..e071606
--- /dev/null
+++ b/internal/module/conversation/model/conversation.go
@@ -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" }
diff --git a/internal/module/conversation/model/conversation_member.go b/internal/module/conversation/model/conversation_member.go
new file mode 100644
index 0000000..2526533
--- /dev/null
+++ b/internal/module/conversation/model/conversation_member.go
@@ -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
+}
diff --git a/internal/module/conversation/model/memory.go b/internal/module/conversation/model/memory.go
new file mode 100644
index 0000000..27cc406
--- /dev/null
+++ b/internal/module/conversation/model/memory.go
@@ -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
+}
diff --git a/internal/module/conversation/model/p2p_conversation.go b/internal/module/conversation/model/p2p_conversation.go
new file mode 100644
index 0000000..c377592
--- /dev/null
+++ b/internal/module/conversation/model/p2p_conversation.go
@@ -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" }
diff --git a/internal/module/conversation/model/repo.go b/internal/module/conversation/model/repo.go
new file mode 100644
index 0000000..210d8e9
--- /dev/null
+++ b/internal/module/conversation/model/repo.go
@@ -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)
+}
diff --git a/internal/module/conversation/repository.go b/internal/module/conversation/repository.go
deleted file mode 100644
index 1fdff57..0000000
--- a/internal/module/conversation/repository.go
+++ /dev/null
@@ -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
-}
diff --git a/internal/module/deliver/entity/job.go b/internal/module/deliver/entity/job.go
new file mode 100644
index 0000000..6fa7763
--- /dev/null
+++ b/internal/module/deliver/entity/job.go
@@ -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
+ }
+}
diff --git a/internal/module/deliver/logic/done.go b/internal/module/deliver/logic/done.go
new file mode 100644
index 0000000..89989db
--- /dev/null
+++ b/internal/module/deliver/logic/done.go
@@ -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)
+}
diff --git a/internal/module/deliver/logic/queue.go b/internal/module/deliver/logic/queue.go
new file mode 100644
index 0000000..1a7221e
--- /dev/null
+++ b/internal/module/deliver/logic/queue.go
@@ -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
+}
diff --git a/internal/module/deliver/logic/worker.go b/internal/module/deliver/logic/worker.go
new file mode 100644
index 0000000..b880ec1
--- /dev/null
+++ b/internal/module/deliver/logic/worker.go
@@ -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))
+ }
+}
diff --git a/internal/module/deliver/logic/worker_test.go b/internal/module/deliver/logic/worker_test.go
new file mode 100644
index 0000000..bf89e42
--- /dev/null
+++ b/internal/module/deliver/logic/worker_test.go
@@ -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())
+ }
+}
diff --git a/internal/module/deliver/model/memory.go b/internal/module/deliver/model/memory.go
new file mode 100644
index 0000000..316642b
--- /dev/null
+++ b/internal/module/deliver/model/memory.go
@@ -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
+}
diff --git a/internal/module/deliver/model/redis_queue.go b/internal/module/deliver/model/redis_queue.go
new file mode 100644
index 0000000..316498e
--- /dev/null
+++ b/internal/module/deliver/model/redis_queue.go
@@ -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
+}
diff --git a/internal/module/deliver/queue.go b/internal/module/deliver/queue.go
deleted file mode 100644
index 907404e..0000000
--- a/internal/module/deliver/queue.go
+++ /dev/null
@@ -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
-}
diff --git a/internal/module/deliver/redis_queue.go b/internal/module/deliver/redis_queue.go
deleted file mode 100644
index 3ec3a2b..0000000
--- a/internal/module/deliver/redis_queue.go
+++ /dev/null
@@ -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
- }
-}
diff --git a/internal/module/favorite/entity/favorite.go b/internal/module/favorite/entity/favorite.go
new file mode 100644
index 0000000..e34151b
--- /dev/null
+++ b/internal/module/favorite/entity/favorite.go
@@ -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
+}
diff --git a/internal/module/favorite/logic/copy.go b/internal/module/favorite/logic/copy.go
new file mode 100644
index 0000000..a72df2a
--- /dev/null
+++ b/internal/module/favorite/logic/copy.go
@@ -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)
+ }
+}
diff --git a/internal/module/favorite/logic/create.go b/internal/module/favorite/logic/create.go
new file mode 100644
index 0000000..4921248
--- /dev/null
+++ b/internal/module/favorite/logic/create.go
@@ -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,
+ }
+}
diff --git a/internal/module/favorite/logic/favorite_test.go b/internal/module/favorite/logic/favorite_test.go
new file mode 100644
index 0000000..970cfd5
--- /dev/null
+++ b/internal/module/favorite/logic/favorite_test.go
@@ -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)
+ }
+}
diff --git a/internal/module/favorite/logic/list.go b/internal/module/favorite/logic/list.go
new file mode 100644
index 0000000..fc91ac5
--- /dev/null
+++ b/internal/module/favorite/logic/list.go
@@ -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())
+}
diff --git a/internal/module/favorite/logic/service.go b/internal/module/favorite/logic/service.go
new file mode 100644
index 0000000..8b81f0a
--- /dev/null
+++ b/internal/module/favorite/logic/service.go
@@ -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
+}
diff --git a/internal/module/favorite/model/favorite.go b/internal/module/favorite/model/favorite.go
new file mode 100644
index 0000000..247860a
--- /dev/null
+++ b/internal/module/favorite/model/favorite.go
@@ -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
+}
diff --git a/internal/module/favorite/model/memory.go b/internal/module/favorite/model/memory.go
new file mode 100644
index 0000000..6c76f46
--- /dev/null
+++ b/internal/module/favorite/model/memory.go
@@ -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
+}
diff --git a/internal/module/favorite/model/repo.go b/internal/module/favorite/model/repo.go
new file mode 100644
index 0000000..a3a9585
--- /dev/null
+++ b/internal/module/favorite/model/repo.go
@@ -0,0 +1,114 @@
+package model
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ mysqldriver "github.com/go-sql-driver/mysql"
+ "gorm.io/gorm"
+
+ "spark-server/internal/module/favorite/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+// Repo is MySQL persistence for favorites.
+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("favorite-mysql", err)
+}
+
+func (m *Repo) Get(ctx context.Context, id int64) (*entity.Favorite, error) {
+ db, err := m.session(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var row Favorite
+ if err := db.Where("id = ?", id).Take(&row).Error; err != nil {
+ return nil, mapDBErr(err)
+ }
+ return row.Entity(), nil
+}
+
+func (m *Repo) GetByUserMsg(ctx context.Context, userID, msgID int64) (*entity.Favorite, error) {
+ db, err := m.session(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var row Favorite
+ if err := db.Where("user_id = ? AND msg_id = ?", userID, msgID).Take(&row).Error; err != nil {
+ return nil, mapDBErr(err)
+ }
+ return row.Entity(), nil
+}
+
+func (m *Repo) Create(ctx context.Context, fav *entity.Favorite) error {
+ if fav == nil || fav.ID <= 0 || fav.UserID <= 0 {
+ return errs.ErrInvalidArgument
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ row := fromEntity(fav)
+ if row.CreatedAt.IsZero() {
+ row.CreatedAt = time.Now()
+ }
+ return mapDBErr(db.Create(row).Error)
+}
+
+func (m *Repo) ListByUser(ctx context.Context, userID int64, limit int) ([]*entity.Favorite, error) {
+ db, err := m.session(ctx)
+ if err != nil {
+ return nil, err
+ }
+ limit = entity.ClampLimit(limit)
+ var rows []Favorite
+ if err := db.Where("user_id = ?", userID).Order("id DESC").Limit(limit).Find(&rows).Error; err != nil {
+ return nil, mapDBErr(err)
+ }
+ out := make([]*entity.Favorite, 0, len(rows))
+ for i := range rows {
+ out = append(out, rows[i].Entity())
+ }
+ return out, nil
+}
+
+func (m *Repo) Delete(ctx context.Context, userID, id int64) error {
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ res := db.Where("id = ? AND user_id = ?", id, userID).Delete(&Favorite{})
+ if res.Error != nil {
+ return mapDBErr(res.Error)
+ }
+ if res.RowsAffected == 0 {
+ return errs.ErrNotFound
+ }
+ return nil
+}
diff --git a/internal/module/friend/entity/accept.go b/internal/module/friend/entity/accept.go
new file mode 100644
index 0000000..a596b38
--- /dev/null
+++ b/internal/module/friend/entity/accept.go
@@ -0,0 +1,8 @@
+package entity
+
+// AcceptParams is the transactional write when two users become friends.
+type AcceptParams struct {
+ Relation *Relation
+ Requests []int64
+ Remarks []Remark
+}
diff --git a/internal/module/friend/entity/block.go b/internal/module/friend/entity/block.go
new file mode 100644
index 0000000..4dcf0fd
--- /dev/null
+++ b/internal/module/friend/entity/block.go
@@ -0,0 +1,10 @@
+package entity
+
+import "time"
+
+// Block is unidirectional: UserID blocked BlockedID.
+type Block struct {
+ UserID int64
+ BlockedID int64
+ CreatedAt time.Time
+}
diff --git a/internal/module/friend/entity/pair.go b/internal/module/friend/entity/pair.go
new file mode 100644
index 0000000..016f8dc
--- /dev/null
+++ b/internal/module/friend/entity/pair.go
@@ -0,0 +1,21 @@
+package entity
+
+import "time"
+
+func OrderUsers(a, b int64) (small, big int64) {
+ if a <= b {
+ return a, b
+ }
+ return b, a
+}
+
+func SameUser(a, b int64) bool {
+ return a > 0 && a == b
+}
+
+func UnixMS(t time.Time) int64 {
+ if t.IsZero() {
+ return 0
+ }
+ return t.UnixMilli()
+}
diff --git a/internal/module/friend/entity/relation.go b/internal/module/friend/entity/relation.go
new file mode 100644
index 0000000..41c3bd7
--- /dev/null
+++ b/internal/module/friend/entity/relation.go
@@ -0,0 +1,60 @@
+package entity
+
+import "time"
+
+const (
+ RelationNone = 0
+ RelationFriend = 1
+ RelationBlock = 2
+)
+
+const (
+ ListDefault = 20
+ ListMax = 100
+)
+
+// Relation is a bidirectional friendship. Natural key (user_small, user_big).
+type Relation struct {
+ UserSmall int64
+ UserBig int64
+ CreatedAt time.Time
+}
+
+func NewRelation(a, b int64, at time.Time) *Relation {
+ small, big := OrderUsers(a, b)
+ return &Relation{UserSmall: small, UserBig: big, CreatedAt: at}
+}
+
+func (r *Relation) Other(me int64) int64 {
+ if r == nil {
+ return 0
+ }
+ if r.UserSmall == me {
+ return r.UserBig
+ }
+ if r.UserBig == me {
+ return r.UserSmall
+ }
+ return 0
+}
+
+func ClampLimit(limit int) int {
+ if limit <= 0 {
+ return ListDefault
+ }
+ if limit > ListMax {
+ return ListMax
+ }
+ return limit
+}
+
+// ViewStatus is the viewer-centric relation: friend, blocked-by-me, or none.
+func ViewStatus(friend, blockedByMe bool) int {
+ if blockedByMe {
+ return RelationBlock
+ }
+ if friend {
+ return RelationFriend
+ }
+ return RelationNone
+}
diff --git a/internal/module/friend/entity/remark.go b/internal/module/friend/entity/remark.go
new file mode 100644
index 0000000..04b8226
--- /dev/null
+++ b/internal/module/friend/entity/remark.go
@@ -0,0 +1,11 @@
+package entity
+
+import "time"
+
+// Remark is viewer-only (UserID's note on FriendID).
+type Remark struct {
+ UserID int64
+ FriendID int64
+ Text string
+ UpdatedAt time.Time
+}
diff --git a/internal/module/friend/entity/report.go b/internal/module/friend/entity/report.go
new file mode 100644
index 0000000..5fa7b7b
--- /dev/null
+++ b/internal/module/friend/entity/report.go
@@ -0,0 +1,26 @@
+package entity
+
+import "time"
+
+const (
+ ReportSpam = 1
+ ReportHarassment = 2
+ ReportFake = 3
+ ReportOther = 4
+)
+
+const ReportDetailMax = 512
+
+// Report is a complaint about TargetID by UserID.
+type Report struct {
+ ID int64
+ UserID int64
+ TargetID int64
+ Reason int
+ Detail string
+ CreatedAt time.Time
+}
+
+func ValidReportReason(r int) bool {
+ return r >= ReportSpam && r <= ReportOther
+}
diff --git a/internal/module/friend/entity/request.go b/internal/module/friend/entity/request.go
new file mode 100644
index 0000000..45383e6
--- /dev/null
+++ b/internal/module/friend/entity/request.go
@@ -0,0 +1,37 @@
+package entity
+
+import "time"
+
+const (
+ RequestPending = 1
+ RequestAccepted = 2
+ RequestRejected = 3
+)
+
+const (
+ SourceQRCode = 1
+ SourceEmail = 2
+ SourceSparkID = 3
+)
+
+const (
+ ReasonMax = 128
+ RemarkMax = 64
+)
+
+// Request is one from→to application. Repeats update this row; they do not insert another.
+type Request struct {
+ ID int64
+ FromUser int64
+ ToUser int64
+ Source int
+ Reason string
+ Remark string
+ Status int
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+func ValidSource(src int) bool {
+ return src == SourceQRCode || src == SourceEmail || src == SourceSparkID
+}
diff --git a/internal/module/friend/entity/system.go b/internal/module/friend/entity/system.go
new file mode 100644
index 0000000..c3f49b8
--- /dev/null
+++ b/internal/module/friend/entity/system.go
@@ -0,0 +1,4 @@
+package entity
+
+// SystemKindBecameFriends is messages.content system.kind after both sides become friends.
+const SystemKindBecameFriends = "friend.became"
diff --git a/internal/module/friend/logic/accept.go b/internal/module/friend/logic/accept.go
new file mode 100644
index 0000000..b10e77b
--- /dev/null
+++ b/internal/module/friend/logic/accept.go
@@ -0,0 +1,73 @@
+package logic
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "time"
+
+ "spark-server/internal/module/friend/entity"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
+ "spark-server/internal/pkg/reqctx"
+ "spark-server/internal/pkg/validate"
+)
+
+// AcceptInput creates a bidirectional friendship from a pending incoming request.
+type AcceptInput struct {
+ RequestID int64 `json:"request_id" validate:"required,gt=0" label:"field.request_id" errmsg:"required:validate.friend.request_required"`
+ Remark string `json:"remark" validate:"omitempty,max=64" label:"field.remark" errmsg:"max:validate.friend.remark_invalid"`
+}
+
+type AcceptResult struct {
+ UID int64 `json:"uid"`
+}
+
+func (s *Service) Accept(ctx context.Context, userID int64, in AcceptInput) (*AcceptResult, error) {
+ if !s.ready() || userID <= 0 {
+ return nil, errs.ErrUnauthorized
+ }
+ if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
+ return nil, err
+ }
+ req, err := s.repo.GetRequest(ctx, in.RequestID)
+ if err != nil {
+ return nil, err
+ }
+ lang := reqctx.Lang(ctx)
+ if req.ToUser != userID || req.Status != entity.RequestPending {
+ return nil, errs.ErrForbidden.WithMsg(i18n.T(lang, i18n.KeyFriendRequestInvalid, nil))
+ }
+ if err := s.canApply(ctx, userID, req.FromUser); err != nil {
+ if !errors.Is(err, errs.ErrDuplicate) {
+ return nil, err
+ }
+ }
+ if err := s.becomeFriends(ctx, req, userID, strings.TrimSpace(in.Remark)); err != nil {
+ return nil, err
+ }
+ return &AcceptResult{UID: req.FromUser}, nil
+}
+
+func (s *Service) becomeFriends(ctx context.Context, req *entity.Request, accepter int64, accepterRemark string) error {
+ now := time.Now()
+ rel := entity.NewRelation(req.FromUser, req.ToUser, now)
+ ids := []int64{req.ID}
+ if reverse, err := s.repo.GetRequestByPair(ctx, accepter, req.FromUser); err == nil && reverse.ID != req.ID {
+ ids = append(ids, reverse.ID)
+ }
+ remarks := make([]entity.Remark, 0, 2)
+ if strings.TrimSpace(req.Remark) != "" {
+ remarks = append(remarks, entity.Remark{UserID: req.FromUser, FriendID: req.ToUser, Text: req.Remark, UpdatedAt: now})
+ }
+ if accepterRemark != "" {
+ remarks = append(remarks, entity.Remark{UserID: accepter, FriendID: req.FromUser, Text: accepterRemark, UpdatedAt: now})
+ }
+ if err := s.repo.AcceptFriend(ctx, entity.AcceptParams{Relation: rel, Requests: ids, Remarks: remarks}); err != nil {
+ return err
+ }
+ if s.greeter != nil {
+ _ = s.greeter.BecameFriends(ctx, req.FromUser, req.ToUser)
+ }
+ return nil
+}
diff --git a/internal/module/friend/logic/apply.go b/internal/module/friend/logic/apply.go
new file mode 100644
index 0000000..b1adcc7
--- /dev/null
+++ b/internal/module/friend/logic/apply.go
@@ -0,0 +1,183 @@
+package logic
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "time"
+
+ "spark-server/internal/module/friend/entity"
+ userentity "spark-server/internal/module/user/entity"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
+ "spark-server/internal/pkg/reqctx"
+ "spark-server/internal/pkg/validate"
+)
+
+// ApplyInput starts a friend request. Exactly one of uid / email / spark_id / qrcode.
+type ApplyInput struct {
+ UID int64 `json:"uid" validate:"omitempty,gt=0" label:"field.uid"`
+ Email string `json:"email" validate:"omitempty,email" label:"field.email" errmsg:"email:validate.login.email_invalid"`
+ SparkID string `json:"spark_id" validate:"omitempty,min=6,max=32" label:"field.spark_id" errmsg:"min:validate.user.spark_id_invalid;max:validate.user.spark_id_invalid"`
+ QRCode string `json:"qrcode" validate:"omitempty,max=64" label:"field.qrcode"`
+ Reason string `json:"reason" validate:"omitempty,max=128" label:"field.reason" errmsg:"max:validate.friend.reason_invalid"`
+ Remark string `json:"remark" validate:"omitempty,max=64" label:"field.remark" errmsg:"max:validate.friend.remark_invalid"`
+}
+
+// ApplyResult is returned after applying or auto-accepting a reverse request.
+type ApplyResult struct {
+ Status string `json:"status"`
+ RequestID int64 `json:"request_id,omitempty"`
+ UID int64 `json:"uid"`
+}
+
+func (s *Service) Apply(ctx context.Context, fromUserID int64, in *ApplyInput) (*ApplyResult, error) {
+ if !s.ready() || fromUserID <= 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
+ }
+ target, source, err := s.resolveTarget(ctx, in)
+ if err != nil {
+ return nil, err
+ }
+ if err := s.canApply(ctx, fromUserID, target.ID); err != nil {
+ return nil, err
+ }
+ if reverse, err := s.repo.GetRequestByPair(ctx, target.ID, fromUserID); err == nil && reverse.Status == entity.RequestPending {
+ if err := s.becomeFriends(ctx, reverse, fromUserID, strings.TrimSpace(in.Remark)); err != nil {
+ return nil, err
+ }
+ return &ApplyResult{Status: "accepted", UID: target.ID}, nil
+ } else if err != nil && !errors.Is(err, errs.ErrNotFound) {
+ return nil, err
+ }
+ req, err := s.upsertPending(ctx, fromUserID, target.ID, source, in)
+ if err != nil {
+ return nil, err
+ }
+ return &ApplyResult{Status: "pending", RequestID: req.ID, UID: target.ID}, nil
+}
+
+func (s *Service) resolveTarget(ctx context.Context, in *ApplyInput) (*userentity.User, int, error) {
+ lang := reqctx.Lang(ctx)
+ n := 0
+ if in.UID > 0 {
+ n++
+ }
+ if strings.TrimSpace(in.Email) != "" {
+ n++
+ }
+ if strings.TrimSpace(in.SparkID) != "" {
+ n++
+ }
+ if strings.TrimSpace(in.QRCode) != "" {
+ n++
+ }
+ if n != 1 {
+ return nil, 0, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyFriendTargetRequired, nil))
+ }
+ switch {
+ case in.UID > 0:
+ u, err := s.activeUser(ctx, in.UID)
+ return u, entity.SourceSparkID, err
+ case strings.TrimSpace(in.Email) != "":
+ u, err := s.users.GetByEmail(ctx, userentity.NormalizeEmail(in.Email))
+ return s.ensureActive(ctx, u, err, entity.SourceEmail)
+ case strings.TrimSpace(in.SparkID) != "":
+ u, err := s.users.GetBySparkID(ctx, strings.TrimSpace(in.SparkID))
+ return s.ensureActive(ctx, u, err, entity.SourceSparkID)
+ default:
+ raw := strings.TrimSpace(in.QRCode)
+ if !userentity.ValidQRCode(raw) {
+ return nil, 0, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyFriendQRInvalid, nil))
+ }
+ u, err := s.users.GetByQRCode(ctx, raw)
+ return s.ensureActive(ctx, u, err, entity.SourceQRCode)
+ }
+}
+
+func (s *Service) ensureActive(_ context.Context, u *userentity.User, err error, source int) (*userentity.User, int, error) {
+ if err != nil {
+ if errors.Is(err, errs.ErrNotFound) {
+ return nil, 0, errs.ErrNotFound
+ }
+ return nil, 0, err
+ }
+ if u == nil || u.Status != userentity.StatusActive {
+ return nil, 0, errs.ErrNotFound
+ }
+ return u, source, nil
+}
+
+func (s *Service) activeUser(ctx context.Context, id int64) (*userentity.User, error) {
+ u, err := s.users.Get(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ if u == nil || u.Status != userentity.StatusActive {
+ return nil, errs.ErrNotFound
+ }
+ return u, nil
+}
+
+func (s *Service) canApply(ctx context.Context, from, to int64) error {
+ lang := reqctx.Lang(ctx)
+ if entity.SameUser(from, to) {
+ return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyFriendSelf, nil))
+ }
+ blockedMe, err := s.repo.IsBlocked(ctx, from, to)
+ if err != nil {
+ return err
+ }
+ if blockedMe {
+ return errs.ErrForbidden.WithMsg(i18n.T(lang, i18n.KeyFriendBlocked, nil))
+ }
+ blockedBy, err := s.repo.IsBlocked(ctx, to, from)
+ if err != nil {
+ return err
+ }
+ if blockedBy {
+ return errs.ErrNotFound
+ }
+ if _, err := s.repo.GetRelation(ctx, from, to); err == nil {
+ return errs.ErrDuplicate.WithMsg(i18n.T(lang, i18n.KeyFriendAlready, nil))
+ } else if !errors.Is(err, errs.ErrNotFound) {
+ return err
+ }
+ return nil
+}
+
+func (s *Service) upsertPending(ctx context.Context, from, to int64, source int, in *ApplyInput) (*entity.Request, error) {
+ now := time.Now()
+ req := &entity.Request{
+ FromUser: from, ToUser: to, Source: source,
+ Reason: strings.TrimSpace(in.Reason), Remark: strings.TrimSpace(in.Remark),
+ Status: entity.RequestPending, CreatedAt: now, UpdatedAt: now,
+ }
+ existing, err := s.repo.GetRequestByPair(ctx, from, to)
+ switch {
+ case err == nil:
+ req.ID = existing.ID
+ req.CreatedAt = existing.CreatedAt
+ if req.Remark == "" {
+ req.Remark = existing.Remark
+ }
+ case errors.Is(err, errs.ErrNotFound):
+ id, err := s.ids.NextID()
+ if err != nil {
+ return nil, err
+ }
+ req.ID = id
+ default:
+ return nil, err
+ }
+ if err := s.repo.UpsertRequest(ctx, req); err != nil {
+ return nil, err
+ }
+ return req, nil
+}
diff --git a/internal/module/friend/logic/block.go b/internal/module/friend/logic/block.go
new file mode 100644
index 0000000..8044de1
--- /dev/null
+++ b/internal/module/friend/logic/block.go
@@ -0,0 +1,54 @@
+package logic
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "spark-server/internal/module/friend/entity"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
+ "spark-server/internal/pkg/reqctx"
+)
+
+func (s *Service) Block(ctx context.Context, userID, targetID int64) error {
+ if !s.ready() || userID <= 0 {
+ return errs.ErrUnauthorized
+ }
+ if targetID <= 0 {
+ return errs.ErrInvalidArgument
+ }
+ if entity.SameUser(userID, targetID) {
+ return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyFriendSelf, nil))
+ }
+ if _, err := s.activeUser(ctx, targetID); err != nil {
+ return err
+ }
+ return s.repo.CreateBlock(ctx, &entity.Block{UserID: userID, BlockedID: targetID, CreatedAt: time.Now()})
+}
+
+func (s *Service) Unblock(ctx context.Context, userID, targetID int64) error {
+ if !s.ready() || userID <= 0 {
+ return errs.ErrUnauthorized
+ }
+ if targetID <= 0 {
+ return errs.ErrInvalidArgument
+ }
+ return s.repo.DeleteBlock(ctx, userID, targetID)
+}
+
+func (s *Service) Delete(ctx context.Context, userID, friendID int64) error {
+ if !s.ready() || userID <= 0 {
+ return errs.ErrUnauthorized
+ }
+ if friendID <= 0 {
+ return errs.ErrInvalidArgument
+ }
+ if err := s.repo.DeleteFriend(ctx, userID, friendID); err != nil {
+ if errors.Is(err, errs.ErrNotFound) {
+ return errs.ErrNotFound.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyFriendNotFriend, nil))
+ }
+ return err
+ }
+ return nil
+}
diff --git a/internal/module/friend/logic/cansend.go b/internal/module/friend/logic/cansend.go
new file mode 100644
index 0000000..9fc7ad9
--- /dev/null
+++ b/internal/module/friend/logic/cansend.go
@@ -0,0 +1,31 @@
+package logic
+
+import (
+ "context"
+ "errors"
+
+ "spark-server/internal/pkg/errs"
+)
+
+func (s *Service) CanSend(ctx context.Context, fromUserID, toUserID int64) error {
+ if !s.ready() || fromUserID <= 0 || toUserID <= 0 {
+ return errs.ErrForbidden
+ }
+ if fromUserID == toUserID {
+ return errs.ErrForbidden
+ }
+ if _, err := s.repo.GetRelation(ctx, fromUserID, toUserID); err != nil {
+ if errors.Is(err, errs.ErrNotFound) {
+ return errs.ErrForbidden
+ }
+ return err
+ }
+ blocked, err := s.repo.BlockedEither(ctx, fromUserID, toUserID)
+ if err != nil {
+ return err
+ }
+ if blocked {
+ return errs.ErrForbidden
+ }
+ return nil
+}
diff --git a/internal/module/friend/logic/friend_test.go b/internal/module/friend/logic/friend_test.go
new file mode 100644
index 0000000..09ca6aa
--- /dev/null
+++ b/internal/module/friend/logic/friend_test.go
@@ -0,0 +1,220 @@
+package logic
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "spark-server/internal/infrastructure/id"
+ convlogic "spark-server/internal/module/conversation/logic"
+ convmodel "spark-server/internal/module/conversation/model"
+ delivermodel "spark-server/internal/module/deliver/model"
+ "spark-server/internal/module/friend/entity"
+ friendmodel "spark-server/internal/module/friend/model"
+ msgentity "spark-server/internal/module/message/entity"
+ msgmodel "spark-server/internal/module/message/model"
+ userentity "spark-server/internal/module/user/entity"
+ userlogic "spark-server/internal/module/user/logic"
+ usermodel "spark-server/internal/module/user/model"
+ "spark-server/internal/pkg/errs"
+)
+
+type captureGreeter struct {
+ n int
+ a, b int64
+}
+
+func (c *captureGreeter) BecameFriends(_ context.Context, a, b int64) error {
+ c.n++
+ c.a, c.b = a, b
+ return nil
+}
+
+func testFriends(t *testing.T) (*Service, *userlogic.Service, *captureGreeter) {
+ t.Helper()
+ ids, err := id.New(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil)
+ g := &captureGreeter{}
+ return NewService(friendmodel.NewMemoryRepo(), users, ids, g), users, g
+}
+
+func twoUsers(t *testing.T, users *userlogic.Service) (*userentity.User, *userentity.User) {
+ t.Helper()
+ ctx := t.Context()
+ a, err := users.AuthenticateEmail(ctx, "test01@spark.com", "1", &userentity.LoginDevice{DeviceID: 1, DeviceType: userentity.DeviceIOS})
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := users.AuthenticateEmail(ctx, "test02@spark.com", "1", &userentity.LoginDevice{DeviceID: 2, DeviceType: userentity.DeviceWeb})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return a, b
+}
+
+func TestApplyByEmailSparkIDQRAndDedupe(t *testing.T) {
+ svc, users, _ := testFriends(t)
+ ctx := t.Context()
+ a, b := twoUsers(t, users)
+ first, err := svc.Apply(ctx, a.ID, &ApplyInput{Email: "test02@spark.com", Reason: "hi", Remark: "work"})
+ if err != nil || first.Status != "pending" || first.RequestID == 0 {
+ t.Fatalf("email apply %+v %v", first, err)
+ }
+ second, err := svc.Apply(ctx, a.ID, &ApplyInput{SparkID: b.SparkID, Reason: "again"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if second.RequestID != first.RequestID {
+ t.Fatalf("dedupe %d %d", first.RequestID, second.RequestID)
+ }
+ third, err := svc.Apply(ctx, a.ID, &ApplyInput{QRCode: b.QRCode, Reason: "qr"})
+ if err != nil || third.RequestID != first.RequestID {
+ t.Fatalf("qr %+v %v", third, err)
+ }
+ list, err := svc.ListRequests(ctx, b.ID, 20)
+ if err != nil || len(list.Items) != 1 || list.Items[0].Reason != "qr" || list.Items[0].From.UID != a.ID {
+ t.Fatalf("inbox %+v %v", list, err)
+ }
+}
+
+func TestAcceptBecomesFriendsAndGreeting(t *testing.T) {
+ svc, users, g := testFriends(t)
+ ctx := t.Context()
+ a, b := twoUsers(t, users)
+ applied, err := svc.Apply(ctx, a.ID, &ApplyInput{Email: "test02@spark.com", Remark: "from-a"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := svc.Accept(ctx, b.ID, AcceptInput{RequestID: applied.RequestID, Remark: "from-b"})
+ if err != nil || got.UID != a.ID {
+ t.Fatalf("accept %+v %v", got, err)
+ }
+ if g.n != 1 {
+ t.Fatalf("greeting %d", g.n)
+ }
+ if err := svc.CanSend(ctx, a.ID, b.ID); err != nil {
+ t.Fatal(err)
+ }
+ view, err := svc.Get(ctx, a.ID, b.ID)
+ if err != nil || view.Remark != "from-a" || view.Relation != entity.RelationFriend {
+ t.Fatalf("a view %+v %v", view, err)
+ }
+ view, err = svc.Get(ctx, b.ID, a.ID)
+ if err != nil || view.Remark != "from-b" {
+ t.Fatalf("b view %+v %v", view, err)
+ }
+ if _, err := svc.Get(ctx, a.ID, a.ID); !errors.Is(err, errs.ErrNotFound) {
+ t.Fatalf("self %v", err)
+ }
+}
+
+func TestMutualApplyAutoAccept(t *testing.T) {
+ svc, users, g := testFriends(t)
+ ctx := t.Context()
+ a, b := twoUsers(t, users)
+ if _, err := svc.Apply(ctx, a.ID, &ApplyInput{UID: b.ID, Remark: "a-note"}); err != nil {
+ t.Fatal(err)
+ }
+ out, err := svc.Apply(ctx, b.ID, &ApplyInput{Email: "test01@spark.com", Remark: "b-note"})
+ if err != nil || out.Status != "accepted" {
+ t.Fatalf("%+v %v", out, err)
+ }
+ if g.n != 1 {
+ t.Fatalf("greeting %d", g.n)
+ }
+ if err := svc.CanSend(ctx, b.ID, a.ID); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestNoUnidirectionalDeleteAndBlock(t *testing.T) {
+ svc, users, _ := testFriends(t)
+ ctx := t.Context()
+ a, b := twoUsers(t, users)
+ applied, _ := svc.Apply(ctx, a.ID, &ApplyInput{Email: "test02@spark.com"})
+ if _, err := svc.Accept(ctx, b.ID, AcceptInput{RequestID: applied.RequestID}); err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.Delete(ctx, a.ID, b.ID); err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.CanSend(ctx, b.ID, a.ID); !errors.Is(err, errs.ErrForbidden) {
+ t.Fatalf("after delete %v", err)
+ }
+ applied, _ = svc.Apply(ctx, a.ID, &ApplyInput{Email: "test02@spark.com"})
+ if _, err := svc.Accept(ctx, b.ID, AcceptInput{RequestID: applied.RequestID}); err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.Block(ctx, a.ID, b.ID); err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.CanSend(ctx, a.ID, b.ID); !errors.Is(err, errs.ErrForbidden) {
+ t.Fatalf("blocked send %v", err)
+ }
+ if _, err := svc.Apply(ctx, a.ID, &ApplyInput{Email: "test02@spark.com"}); !errors.Is(err, errs.ErrForbidden) {
+ t.Fatalf("apply blocked %v", err)
+ }
+ if _, err := svc.Apply(ctx, b.ID, &ApplyInput{Email: "test01@spark.com"}); !errors.Is(err, errs.ErrNotFound) {
+ t.Fatalf("apply blocked-by %v", err)
+ }
+}
+
+func TestRemarkReportLookup(t *testing.T) {
+ svc, users, _ := testFriends(t)
+ ctx := t.Context()
+ a, b := twoUsers(t, users)
+ look, err := svc.Lookup(ctx, a.ID, LookupInput{SparkID: b.SparkID})
+ if err != nil || look.UID != b.ID || look.Relation != entity.RelationNone {
+ t.Fatalf("lookup %+v %v", look, err)
+ }
+ applied, _ := svc.Apply(ctx, a.ID, &ApplyInput{SparkID: b.SparkID})
+ if _, err := svc.Accept(ctx, b.ID, AcceptInput{RequestID: applied.RequestID}); err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.SetRemark(ctx, a.ID, b.ID, RemarkInput{Remark: "boss"}); err != nil {
+ t.Fatal(err)
+ }
+ view, err := svc.Get(ctx, a.ID, b.ID)
+ if err != nil || view.Remark != "boss" {
+ t.Fatalf("remark %+v %v", view, err)
+ }
+ if err := svc.Report(ctx, a.ID, b.ID, ReportInput{Reason: entity.ReportSpam, Detail: "ads"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := svc.Apply(ctx, a.ID, &ApplyInput{Email: "test02@spark.com"}); !errors.Is(err, errs.ErrDuplicate) {
+ t.Fatalf("already %v", err)
+ }
+}
+
+func TestSystemGreeterWritesMessage(t *testing.T) {
+ ids, err := id.New(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ msgs := msgmodel.NewMemoryRepo()
+ chats := convlogic.NewP2PService(convmodel.NewMemoryP2P(), ids)
+ g := NewGreeter(chats, msgs, ids, delivermodel.NewMemoryQueue())
+ if err := g.BecameFriends(t.Context(), 11, 22); err != nil {
+ t.Fatal(err)
+ }
+ convID, err := chats.EnsureP2P(t.Context(), 11, 22)
+ if err != nil || convID == 0 {
+ t.Fatalf("conv %d %v", convID, err)
+ }
+ found := false
+ for _, msg := range msgs.All() {
+ if msg.ConversationID == convID && msg.Type == msgentity.TypeSystem {
+ found = true
+ payload, err := msg.Payload()
+ if err != nil || payload.System == nil || payload.System.Kind != entity.SystemKindBecameFriends {
+ t.Fatalf("payload %+v %v", payload, err)
+ }
+ }
+ }
+ if !found {
+ t.Fatal("missing system message")
+ }
+}
diff --git a/internal/module/friend/logic/friends.go b/internal/module/friend/logic/friends.go
new file mode 100644
index 0000000..d116dc3
--- /dev/null
+++ b/internal/module/friend/logic/friends.go
@@ -0,0 +1,22 @@
+package logic
+
+import (
+ "context"
+ "errors"
+
+ "spark-server/internal/pkg/errs"
+)
+
+func (s *Service) AreFriends(ctx context.Context, a, b int64) (bool, error) {
+ if !s.ready() || a <= 0 || b <= 0 || a == b {
+ return false, nil
+ }
+ _, err := s.repo.GetRelation(ctx, a, b)
+ if err == nil {
+ return true, nil
+ }
+ if errors.Is(err, errs.ErrNotFound) {
+ return false, nil
+ }
+ return false, err
+}
diff --git a/internal/module/friend/logic/greeting.go b/internal/module/friend/logic/greeting.go
new file mode 100644
index 0000000..105b52d
--- /dev/null
+++ b/internal/module/friend/logic/greeting.go
@@ -0,0 +1,94 @@
+package logic
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ deliverentity "spark-server/internal/module/deliver/entity"
+ friendentity "spark-server/internal/module/friend/entity"
+ msgentity "spark-server/internal/module/message/entity"
+ "spark-server/internal/pkg/reply"
+)
+
+const cmdMessage uint16 = 6
+
+// DeliverQueue is Redis deliver:queue Push (C5).
+type DeliverQueue interface {
+ Push(ctx context.Context, job *deliverentity.Job) error
+}
+
+// SystemGreeter inserts a type-9 system message into the P2P conversation and enqueues deliver.
+type SystemGreeter struct {
+ chats P2PChats
+ msgs Messages
+ ids IDGen
+ queue DeliverQueue
+}
+
+func NewGreeter(chats P2PChats, msgs Messages, ids IDGen, queue DeliverQueue) *SystemGreeter {
+ return &SystemGreeter{chats: chats, msgs: msgs, ids: ids, queue: queue}
+}
+
+func (g *SystemGreeter) BecameFriends(ctx context.Context, userA, userB int64) error {
+ if g == nil || g.chats == nil || g.msgs == nil || g.ids == nil {
+ return nil
+ }
+ convID, err := g.chats.EnsureP2P(ctx, userA, userB)
+ if err != nil {
+ return err
+ }
+ msgID, err := g.ids.NextID()
+ if err != nil {
+ return err
+ }
+ now := time.Now()
+ content := msgentity.NewSystem(&msgentity.System{Kind: friendentity.SystemKindBecameFriends})
+ raw, err := content.Marshal()
+ if err != nil {
+ return err
+ }
+ msg := &msgentity.Message{
+ ID: msgID,
+ ConversationID: convID,
+ SenderID: 0,
+ ClientMsgID: fmt.Sprintf("sys:friend:%d", msgID),
+ Type: msgentity.TypeSystem,
+ Content: raw,
+ Status: msgentity.StatusNormal,
+ CreatedAtMS: now.UnixMilli(),
+ }
+ if err := g.msgs.Create(ctx, msg); err != nil {
+ return err
+ }
+ if err := g.chats.UpdateLastMsg(ctx, convID, msgID, now.UnixMilli()); err != nil {
+ return err
+ }
+ if g.queue == nil {
+ return nil
+ }
+ return g.queue.Push(ctx, &deliverentity.Job{
+ MsgID: msgID,
+ Cmd: cmdMessage,
+ ConversationID: convID,
+ ToUserIDs: []int64{userA, userB},
+ Payload: reply.MustMarshal(reply.OK(pushBodyOf(msg))),
+ })
+}
+
+type pushBody struct {
+ MsgID int64 `json:"msg_id"`
+ ConversationID int64 `json:"conversation_id"`
+ SenderID int64 `json:"sender_id"`
+ MessageType int16 `json:"message_type"`
+ Content json.RawMessage `json:"content"`
+ CreatedAt int64 `json:"created_at"`
+}
+
+func pushBodyOf(m *msgentity.Message) pushBody {
+ return pushBody{
+ MsgID: m.ID, ConversationID: m.ConversationID, SenderID: m.SenderID,
+ MessageType: int16(m.Type), Content: m.Content, CreatedAt: m.CreatedAtMS,
+ }
+}
diff --git a/internal/module/friend/logic/lookup.go b/internal/module/friend/logic/lookup.go
new file mode 100644
index 0000000..248a810
--- /dev/null
+++ b/internal/module/friend/logic/lookup.go
@@ -0,0 +1,64 @@
+package logic
+
+import (
+ "context"
+ "errors"
+
+ "spark-server/internal/module/friend/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+// LookupInput finds a user to add. Exactly one identifier.
+type LookupInput struct {
+ UID int64
+ Email string
+ SparkID string
+ QRCode string
+}
+
+func (s *Service) Lookup(ctx context.Context, userID int64, in LookupInput) (*FriendView, error) {
+ if !s.ready() || userID <= 0 {
+ return nil, errs.ErrUnauthorized
+ }
+ target, _, err := s.resolveTarget(ctx, &ApplyInput{
+ UID: in.UID, Email: in.Email, SparkID: in.SparkID, QRCode: in.QRCode,
+ })
+ if err != nil {
+ return nil, err
+ }
+ blockedBy, err := s.repo.IsBlocked(ctx, target.ID, userID)
+ if err != nil {
+ return nil, err
+ }
+ if blockedBy {
+ return nil, errs.ErrNotFound
+ }
+ friend := false
+ if _, err := s.repo.GetRelation(ctx, userID, target.ID); err == nil {
+ friend = true
+ } else if !errors.Is(err, errs.ErrNotFound) {
+ return nil, err
+ }
+ blockedMe, err := s.repo.IsBlocked(ctx, userID, target.ID)
+ if err != nil {
+ return nil, err
+ }
+ remark := ""
+ if friend {
+ remark, err = s.repo.GetRemark(ctx, userID, target.ID)
+ if err != nil {
+ return nil, err
+ }
+ }
+ view := &FriendView{
+ UserCard: cardOf(target),
+ Remark: remark,
+ Relation: entity.ViewStatus(friend, blockedMe),
+ }
+ if friend {
+ if rel, err := s.repo.GetRelation(ctx, userID, target.ID); err == nil {
+ view.CreatedAt = entity.UnixMS(rel.CreatedAt)
+ }
+ }
+ return view, nil
+}
diff --git a/internal/module/friend/logic/profile.go b/internal/module/friend/logic/profile.go
new file mode 100644
index 0000000..b0a421a
--- /dev/null
+++ b/internal/module/friend/logic/profile.go
@@ -0,0 +1,104 @@
+package logic
+
+import (
+ "context"
+ "errors"
+
+ "spark-server/internal/module/friend/entity"
+ userentity "spark-server/internal/module/user/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+type UserCard struct {
+ UID int64 `json:"uid"`
+ SparkID string `json:"spark_id"`
+ Nickname string `json:"nickname"`
+ Avatar string `json:"avatar"`
+ Gender int `json:"gender"`
+}
+
+type FriendView struct {
+ UserCard
+ Remark string `json:"remark"`
+ Relation int `json:"relation"`
+ CreatedAt int64 `json:"created_at,omitempty"`
+}
+
+type FriendList struct {
+ Items []*FriendView `json:"items"`
+}
+
+func cardOf(u *userentity.User) UserCard {
+ if u == nil {
+ return UserCard{}
+ }
+ return UserCard{
+ UID: u.ID, SparkID: u.SparkID, Nickname: u.Nickname,
+ Avatar: u.Avatar, Gender: u.Gender,
+ }
+}
+
+func (s *Service) List(ctx context.Context, userID int64, limit int) (*FriendList, error) {
+ if !s.ready() || userID <= 0 {
+ return nil, errs.ErrUnauthorized
+ }
+ rels, err := s.repo.ListFriends(ctx, userID, entity.ClampLimit(limit))
+ if err != nil {
+ return nil, err
+ }
+ ids := make([]int64, 0, len(rels))
+ for _, rel := range rels {
+ if other := rel.Other(userID); other > 0 {
+ ids = append(ids, other)
+ }
+ }
+ notes, err := s.repo.ListRemarks(ctx, userID, ids)
+ if err != nil {
+ return nil, err
+ }
+ items := make([]*FriendView, 0, len(rels))
+ for _, rel := range rels {
+ other := rel.Other(userID)
+ u, err := s.users.Get(ctx, other)
+ if err != nil {
+ continue
+ }
+ items = append(items, &FriendView{
+ UserCard: cardOf(u),
+ Remark: notes[other],
+ Relation: entity.RelationFriend,
+ CreatedAt: entity.UnixMS(rel.CreatedAt),
+ })
+ }
+ return &FriendList{Items: items}, nil
+}
+
+func (s *Service) Get(ctx context.Context, userID, friendID int64) (*FriendView, error) {
+ if !s.ready() || userID <= 0 {
+ return nil, errs.ErrUnauthorized
+ }
+ if friendID <= 0 {
+ return nil, errs.ErrInvalidArgument
+ }
+ rel, err := s.repo.GetRelation(ctx, userID, friendID)
+ if err != nil {
+ if errors.Is(err, errs.ErrNotFound) {
+ return nil, errs.ErrNotFound
+ }
+ return nil, err
+ }
+ u, err := s.activeUser(ctx, friendID)
+ if err != nil {
+ return nil, err
+ }
+ remark, err := s.repo.GetRemark(ctx, userID, friendID)
+ if err != nil {
+ return nil, err
+ }
+ return &FriendView{
+ UserCard: cardOf(u),
+ Remark: remark,
+ Relation: entity.RelationFriend,
+ CreatedAt: entity.UnixMS(rel.CreatedAt),
+ }, nil
+}
diff --git a/internal/module/friend/logic/remark.go b/internal/module/friend/logic/remark.go
new file mode 100644
index 0000000..f559798
--- /dev/null
+++ b/internal/module/friend/logic/remark.go
@@ -0,0 +1,43 @@
+package logic
+
+import (
+ "context"
+ "errors"
+ "strings"
+
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
+ "spark-server/internal/pkg/reqctx"
+ "spark-server/internal/pkg/validate"
+)
+
+type RemarkInput struct {
+ Remark string `json:"remark" validate:"omitempty,max=64" label:"field.remark" errmsg:"max:validate.friend.remark_invalid"`
+}
+
+func (s *Service) SetRemark(ctx context.Context, userID, friendID int64, in RemarkInput) error {
+ if !s.ready() || userID <= 0 {
+ return errs.ErrUnauthorized
+ }
+ if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
+ return err
+ }
+ if err := s.requireFriend(ctx, userID, friendID); err != nil {
+ return err
+ }
+ return s.repo.SetRemark(ctx, userID, friendID, strings.TrimSpace(in.Remark))
+}
+
+func (s *Service) requireFriend(ctx context.Context, userID, friendID int64) error {
+ if friendID <= 0 {
+ return errs.ErrInvalidArgument
+ }
+ _, err := s.repo.GetRelation(ctx, userID, friendID)
+ if err == nil {
+ return nil
+ }
+ if errors.Is(err, errs.ErrNotFound) {
+ return errs.ErrNotFound.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyFriendNotFriend, nil))
+ }
+ return err
+}
diff --git a/internal/module/friend/logic/report.go b/internal/module/friend/logic/report.go
new file mode 100644
index 0000000..a6fdea5
--- /dev/null
+++ b/internal/module/friend/logic/report.go
@@ -0,0 +1,41 @@
+package logic
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "spark-server/internal/module/friend/entity"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
+ "spark-server/internal/pkg/reqctx"
+ "spark-server/internal/pkg/validate"
+)
+
+type ReportInput struct {
+ Reason int `json:"reason" validate:"required,oneof=1 2 3 4" label:"field.report_reason" errmsg:"required:validate.friend.report_reason_required;oneof:validate.friend.report_reason_invalid"`
+ Detail string `json:"detail" validate:"omitempty,max=512" label:"field.report_detail" errmsg:"max:validate.friend.report_detail_invalid"`
+}
+
+func (s *Service) Report(ctx context.Context, userID, targetID int64, in ReportInput) error {
+ if !s.ready() || userID <= 0 {
+ return errs.ErrUnauthorized
+ }
+ if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
+ return err
+ }
+ if targetID <= 0 || entity.SameUser(userID, targetID) {
+ return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyFriendSelf, nil))
+ }
+ if _, err := s.activeUser(ctx, targetID); err != nil {
+ return err
+ }
+ id, err := s.ids.NextID()
+ if err != nil {
+ return err
+ }
+ return s.repo.CreateReport(ctx, &entity.Report{
+ ID: id, UserID: userID, TargetID: targetID,
+ Reason: in.Reason, Detail: strings.TrimSpace(in.Detail), CreatedAt: time.Now(),
+ })
+}
diff --git a/internal/module/friend/logic/requests.go b/internal/module/friend/logic/requests.go
new file mode 100644
index 0000000..4c60c44
--- /dev/null
+++ b/internal/module/friend/logic/requests.go
@@ -0,0 +1,47 @@
+package logic
+
+import (
+ "context"
+
+ "spark-server/internal/module/friend/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+type RequestView struct {
+ ID int64 `json:"id"`
+ From UserCard `json:"from"`
+ Reason string `json:"reason"`
+ Source int `json:"source"`
+ CreatedAt int64 `json:"created_at"`
+ UpdatedAt int64 `json:"updated_at"`
+}
+
+type RequestList struct {
+ Items []*RequestView `json:"items"`
+}
+
+func (s *Service) ListRequests(ctx context.Context, userID int64, limit int) (*RequestList, error) {
+ if !s.ready() || userID <= 0 {
+ return nil, errs.ErrUnauthorized
+ }
+ rows, err := s.repo.ListIncoming(ctx, userID, entity.RequestPending, entity.ClampLimit(limit))
+ if err != nil {
+ return nil, err
+ }
+ items := make([]*RequestView, 0, len(rows))
+ for _, req := range rows {
+ from, err := s.users.Get(ctx, req.FromUser)
+ if err != nil {
+ continue
+ }
+ items = append(items, &RequestView{
+ ID: req.ID,
+ From: cardOf(from),
+ Reason: req.Reason,
+ Source: req.Source,
+ CreatedAt: entity.UnixMS(req.CreatedAt),
+ UpdatedAt: entity.UnixMS(req.UpdatedAt),
+ })
+ }
+ return &RequestList{Items: items}, nil
+}
diff --git a/internal/module/friend/logic/service.go b/internal/module/friend/logic/service.go
new file mode 100644
index 0000000..a2c962c
--- /dev/null
+++ b/internal/module/friend/logic/service.go
@@ -0,0 +1,74 @@
+package logic
+
+import (
+ "context"
+
+ "spark-server/internal/module/friend/entity"
+ msgentity "spark-server/internal/module/message/entity"
+ userentity "spark-server/internal/module/user/entity"
+)
+
+// Repository is friend persistence. Model implements it; do not split this by table.
+type Repository interface {
+ GetRelation(ctx context.Context, a, b int64) (*entity.Relation, error)
+ ListFriends(ctx context.Context, userID int64, limit int) ([]*entity.Relation, error)
+ GetRequest(ctx context.Context, id int64) (*entity.Request, error)
+ GetRequestByPair(ctx context.Context, from, to int64) (*entity.Request, error)
+ UpsertRequest(ctx context.Context, req *entity.Request) error
+ ListIncoming(ctx context.Context, toUserID int64, status, limit int) ([]*entity.Request, error)
+ GetRemark(ctx context.Context, userID, friendID int64) (string, error)
+ ListRemarks(ctx context.Context, userID int64, friendIDs []int64) (map[int64]string, error)
+ SetRemark(ctx context.Context, userID, friendID int64, remark string) error
+ IsBlocked(ctx context.Context, userID, targetID int64) (bool, error)
+ BlockedEither(ctx context.Context, a, b int64) (bool, error)
+ CreateBlock(ctx context.Context, b *entity.Block) error
+ DeleteBlock(ctx context.Context, userID, blockedID int64) error
+ CreateReport(ctx context.Context, r *entity.Report) error
+ AcceptFriend(ctx context.Context, in entity.AcceptParams) error
+ DeleteFriend(ctx context.Context, a, b int64) error
+}
+
+// UserFinder resolves accounts by uid / email / spark_id / qrcode ticket.
+type UserFinder interface {
+ Get(ctx context.Context, id int64) (*userentity.User, error)
+ GetByEmail(ctx context.Context, email string) (*userentity.User, error)
+ GetBySparkID(ctx context.Context, sparkID string) (*userentity.User, error)
+ GetByQRCode(ctx context.Context, qrcode string) (*userentity.User, error)
+}
+
+// IDGen issues Snowflake ids for requests, reports, and system messages.
+type IDGen interface {
+ NextID() (int64, error)
+}
+
+// Greeter writes the "became friends" system message. Nil skips greeting.
+type Greeter interface {
+ BecameFriends(ctx context.Context, userA, userB int64) error
+}
+
+// P2PChats opens (or reuses) the 1:1 conversation for a pair.
+type P2PChats interface {
+ EnsureP2P(ctx context.Context, userA, userB int64) (int64, error)
+ UpdateLastMsg(ctx context.Context, conversationID, msgID, atMS int64) error
+}
+
+// Messages persists a row in messages.
+type Messages interface {
+ Create(ctx context.Context, m *msgentity.Message) error
+}
+
+// Service is the friend use-case layer (HTTP and TCP share it).
+type Service struct {
+ repo Repository
+ users UserFinder
+ ids IDGen
+ greeter Greeter
+}
+
+func NewService(repo Repository, users UserFinder, ids IDGen, greeter Greeter) *Service {
+ return &Service{repo: repo, users: users, ids: ids, greeter: greeter}
+}
+
+func (s *Service) ready() bool {
+ return s != nil && s.repo != nil && s.users != nil && s.ids != nil
+}
diff --git a/internal/module/friend/model/accept.go b/internal/module/friend/model/accept.go
new file mode 100644
index 0000000..782e7c5
--- /dev/null
+++ b/internal/module/friend/model/accept.go
@@ -0,0 +1,55 @@
+package model
+
+import (
+ "context"
+ "time"
+
+ "gorm.io/gorm"
+
+ "spark-server/internal/module/friend/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+func (m *Repo) AcceptFriend(ctx context.Context, in entity.AcceptParams) error {
+ if in.Relation == nil {
+ return errs.ErrInvalidArgument
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ now := in.Relation.CreatedAt
+ if now.IsZero() {
+ now = time.Now()
+ in.Relation.CreatedAt = now
+ }
+ return db.Transaction(func(tx *gorm.DB) error {
+ row := FriendRelation{UserSmall: in.Relation.UserSmall, UserBig: in.Relation.UserBig, CreatedAt: now}
+ if err := tx.Where("user_small = ? AND user_big = ?", row.UserSmall, row.UserBig).
+ Attrs(row).FirstOrCreate(&row).Error; err != nil {
+ return mapDBErr(err)
+ }
+ if err := markRequestsAccepted(tx, in.Requests, now); err != nil {
+ return err
+ }
+ return upsertRemarks(tx, in.Remarks)
+ })
+}
+
+func (m *Repo) DeleteFriend(ctx context.Context, a, b int64) error {
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ return db.Transaction(func(tx *gorm.DB) error {
+ small, big := entity.OrderUsers(a, b)
+ res := tx.Where("user_small = ? AND user_big = ?", small, big).Delete(&FriendRelation{})
+ if res.Error != nil {
+ return mapDBErr(res.Error)
+ }
+ if res.RowsAffected == 0 {
+ return errs.ErrNotFound
+ }
+ return deleteRemarksBetween(tx, a, b)
+ })
+}
diff --git a/internal/module/friend/model/friend_relation.go b/internal/module/friend/model/friend_relation.go
new file mode 100644
index 0000000..91c7823
--- /dev/null
+++ b/internal/module/friend/model/friend_relation.go
@@ -0,0 +1,56 @@
+package model
+
+import (
+ "context"
+ "time"
+
+ "spark-server/internal/module/friend/entity"
+)
+
+// FriendRelation maps table friend_relations.
+type FriendRelation struct {
+ UserSmall int64 `gorm:"column:user_small;primaryKey;comment:两人中较小的 user_id,保证同一对用户只有一行"`
+ UserBig int64 `gorm:"column:user_big;primaryKey;comment:两人中较大的 user_id"`
+ CreatedAt time.Time `gorm:"column:created_at;comment:成为好友时间"`
+}
+
+func (FriendRelation) TableName() string { return "friend_relations" }
+
+func (r *FriendRelation) entity() *entity.Relation {
+ if r == nil {
+ return nil
+ }
+ return &entity.Relation{UserSmall: r.UserSmall, UserBig: r.UserBig, CreatedAt: r.CreatedAt}
+}
+
+func (m *Repo) GetRelation(ctx context.Context, a, b int64) (*entity.Relation, error) {
+ db, err := m.session(ctx)
+ if err != nil {
+ return nil, err
+ }
+ small, big := entity.OrderUsers(a, b)
+ var row FriendRelation
+ if err := db.Where("user_small = ? AND user_big = ?", small, big).Take(&row).Error; err != nil {
+ return nil, mapDBErr(err)
+ }
+ return row.entity(), nil
+}
+
+func (m *Repo) ListFriends(ctx context.Context, userID int64, limit int) ([]*entity.Relation, error) {
+ db, err := m.session(ctx)
+ if err != nil {
+ return nil, err
+ }
+ limit = entity.ClampLimit(limit)
+ var rows []FriendRelation
+ err = db.Where("user_small = ? OR user_big = ?", userID, userID).
+ Order("created_at DESC").Limit(limit).Find(&rows).Error
+ if err != nil {
+ return nil, mapDBErr(err)
+ }
+ out := make([]*entity.Relation, 0, len(rows))
+ for i := range rows {
+ out = append(out, rows[i].entity())
+ }
+ return out, nil
+}
diff --git a/internal/module/friend/model/friend_remark.go b/internal/module/friend/model/friend_remark.go
new file mode 100644
index 0000000..93c9b4c
--- /dev/null
+++ b/internal/module/friend/model/friend_remark.go
@@ -0,0 +1,89 @@
+package model
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "gorm.io/gorm"
+
+ "spark-server/internal/module/friend/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+// FriendRemark maps table friend_remarks.
+type FriendRemark struct {
+ UserID int64 `gorm:"column:user_id;primaryKey;comment:备注所属用户"`
+ FriendID int64 `gorm:"column:friend_id;primaryKey;comment:被备注的用户"`
+ Remark string `gorm:"column:remark;comment:备注名,仅对备注者可见"`
+ UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间"`
+}
+
+func (FriendRemark) TableName() string { return "friend_remarks" }
+
+func (m *Repo) GetRemark(ctx context.Context, userID, friendID int64) (string, error) {
+ db, err := m.session(ctx)
+ if err != nil {
+ return "", err
+ }
+ var row FriendRemark
+ if err := db.Where("user_id = ? AND friend_id = ?", userID, friendID).Take(&row).Error; err != nil {
+ mapped := mapDBErr(err)
+ if errors.Is(mapped, errs.ErrNotFound) {
+ return "", nil
+ }
+ return "", mapped
+ }
+ return row.Remark, nil
+}
+
+func (m *Repo) ListRemarks(ctx context.Context, userID int64, friendIDs []int64) (map[int64]string, error) {
+ out := make(map[int64]string, len(friendIDs))
+ if len(friendIDs) == 0 {
+ return out, nil
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var rows []FriendRemark
+ if err := db.Where("user_id = ? AND friend_id IN ?", userID, friendIDs).Find(&rows).Error; err != nil {
+ return nil, mapDBErr(err)
+ }
+ for i := range rows {
+ out[rows[i].FriendID] = rows[i].Remark
+ }
+ return out, nil
+}
+
+func (m *Repo) SetRemark(ctx context.Context, userID, friendID int64, remark string) error {
+ if userID <= 0 || friendID <= 0 {
+ return errs.ErrInvalidArgument
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ row := FriendRemark{UserID: userID, FriendID: friendID, Remark: remark, UpdatedAt: time.Now()}
+ return mapDBErr(db.Save(&row).Error)
+}
+
+func upsertRemarks(tx *gorm.DB, remarks []entity.Remark) error {
+ for i := range remarks {
+ r := remarks[i]
+ if r.UserID <= 0 || r.FriendID <= 0 || r.Text == "" {
+ continue
+ }
+ row := FriendRemark{UserID: r.UserID, FriendID: r.FriendID, Remark: r.Text, UpdatedAt: r.UpdatedAt}
+ if err := tx.Save(&row).Error; err != nil {
+ return mapDBErr(err)
+ }
+ }
+ return nil
+}
+
+func deleteRemarksBetween(tx *gorm.DB, a, b int64) error {
+ err := tx.Where("(user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)", a, b, b, a).
+ Delete(&FriendRemark{}).Error
+ return mapDBErr(err)
+}
diff --git a/internal/module/friend/model/friend_report.go b/internal/module/friend/model/friend_report.go
new file mode 100644
index 0000000..f45b267
--- /dev/null
+++ b/internal/module/friend/model/friend_report.go
@@ -0,0 +1,36 @@
+package model
+
+import (
+ "context"
+ "time"
+
+ "spark-server/internal/module/friend/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+// FriendReport maps table friend_reports.
+type FriendReport struct {
+ ID int64 `gorm:"column:id;primaryKey;comment:Snowflake 投诉 id"`
+ UserID int64 `gorm:"column:user_id;comment:投诉人 user_id"`
+ TargetID int64 `gorm:"column:target_id;comment:被投诉人 user_id"`
+ Reason int `gorm:"column:reason;comment:投诉类型:1=垃圾 2=骚扰 3=不实身份 4=其他"`
+ Detail string `gorm:"column:detail;comment:投诉说明"`
+ CreatedAt time.Time `gorm:"column:created_at;comment:投诉时间"`
+}
+
+func (FriendReport) TableName() string { return "friend_reports" }
+
+func (m *Repo) CreateReport(ctx context.Context, r *entity.Report) error {
+ if r == nil || r.UserID <= 0 || r.TargetID <= 0 {
+ return errs.ErrInvalidArgument
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ row := FriendReport{
+ ID: r.ID, UserID: r.UserID, TargetID: r.TargetID,
+ Reason: r.Reason, Detail: r.Detail, CreatedAt: r.CreatedAt,
+ }
+ return mapDBErr(db.Create(&row).Error)
+}
diff --git a/internal/module/friend/model/friend_request.go b/internal/module/friend/model/friend_request.go
new file mode 100644
index 0000000..7acae3d
--- /dev/null
+++ b/internal/module/friend/model/friend_request.go
@@ -0,0 +1,132 @@
+package model
+
+import (
+ "context"
+ "time"
+
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+
+ "spark-server/internal/module/friend/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+// FriendRequest maps table friend_requests.
+type FriendRequest struct {
+ ID int64 `gorm:"column:id;primaryKey;comment:Snowflake 申请 id"`
+ FromUser int64 `gorm:"column:from_user;comment:申请人 user_id"`
+ ToUser int64 `gorm:"column:to_user;comment:被申请人 user_id"`
+ Source int `gorm:"column:source;comment:来源:1=二维码 2=邮箱 3=spark_id"`
+ Reason string `gorm:"column:reason;comment:申请理由"`
+ Remark string `gorm:"column:remark;comment:申请人对对方的备注,同意后写入 friend_remarks"`
+ Status int `gorm:"column:status;comment:1=待处理 2=已同意 3=已拒绝"`
+ CreatedAt time.Time `gorm:"column:created_at;comment:首次申请时间"`
+ UpdatedAt time.Time `gorm:"column:updated_at;comment:最近一次申请或处理时间"`
+}
+
+func (FriendRequest) TableName() string { return "friend_requests" }
+
+func (r *FriendRequest) entity() *entity.Request {
+ if r == nil {
+ return nil
+ }
+ return &entity.Request{
+ ID: r.ID, FromUser: r.FromUser, ToUser: r.ToUser, Source: r.Source,
+ Reason: r.Reason, Remark: r.Remark, Status: r.Status,
+ CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
+ }
+}
+
+func requestFromEntity(r *entity.Request) FriendRequest {
+ return FriendRequest{
+ ID: r.ID, FromUser: r.FromUser, ToUser: r.ToUser, Source: r.Source,
+ Reason: r.Reason, Remark: r.Remark, Status: r.Status,
+ CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
+ }
+}
+
+func (m *Repo) GetRequest(ctx context.Context, id int64) (*entity.Request, error) {
+ db, err := m.session(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var row FriendRequest
+ if err := db.Where("id = ?", id).Take(&row).Error; err != nil {
+ return nil, mapDBErr(err)
+ }
+ return row.entity(), nil
+}
+
+func (m *Repo) GetRequestByPair(ctx context.Context, from, to int64) (*entity.Request, error) {
+ db, err := m.session(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var row FriendRequest
+ if err := db.Where("from_user = ? AND to_user = ?", from, to).Take(&row).Error; err != nil {
+ return nil, mapDBErr(err)
+ }
+ return row.entity(), nil
+}
+
+func (m *Repo) UpsertRequest(ctx context.Context, req *entity.Request) error {
+ if req == nil || req.FromUser <= 0 || req.ToUser <= 0 {
+ return errs.ErrInvalidArgument
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ row := requestFromEntity(req)
+ err = db.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "from_user"}, {Name: "to_user"}},
+ DoUpdates: clause.AssignmentColumns([]string{"source", "reason", "remark", "status", "updated_at"}),
+ }).Create(&row).Error
+ if err != nil {
+ return mapDBErr(err)
+ }
+ saved, err := m.GetRequestByPair(ctx, req.FromUser, req.ToUser)
+ if err != nil {
+ return err
+ }
+ *req = *saved
+ return nil
+}
+
+func (m *Repo) ListIncoming(ctx context.Context, toUserID int64, status, limit int) ([]*entity.Request, error) {
+ db, err := m.session(ctx)
+ if err != nil {
+ return nil, err
+ }
+ limit = entity.ClampLimit(limit)
+ var rows []FriendRequest
+ q := db.Where("to_user = ?", toUserID)
+ if status > 0 {
+ q = q.Where("status = ?", status)
+ }
+ if err := q.Order("updated_at DESC").Limit(limit).Find(&rows).Error; err != nil {
+ return nil, mapDBErr(err)
+ }
+ out := make([]*entity.Request, 0, len(rows))
+ for i := range rows {
+ out = append(out, rows[i].entity())
+ }
+ return out, nil
+}
+
+func markRequestsAccepted(tx *gorm.DB, ids []int64, at time.Time) error {
+ if len(ids) == 0 {
+ return nil
+ }
+ return mapDBErr(tx.Model(&FriendRequest{}).Where("id IN ?", ids).Updates(map[string]any{
+ "status": entity.RequestAccepted,
+ "updated_at": at,
+ }).Error)
+}
+
+func rejectPairRequests(tx *gorm.DB, a, b int64, at time.Time) error {
+ return mapDBErr(tx.Model(&FriendRequest{}).
+ Where("(from_user = ? AND to_user = ?) OR (from_user = ? AND to_user = ?)", a, b, b, a).
+ Where("status = ?", entity.RequestPending).
+ Updates(map[string]any{"status": entity.RequestRejected, "updated_at": at}).Error)
+}
diff --git a/internal/module/friend/model/memory.go b/internal/module/friend/model/memory.go
new file mode 100644
index 0000000..926992b
--- /dev/null
+++ b/internal/module/friend/model/memory.go
@@ -0,0 +1,153 @@
+package model
+
+import (
+ "context"
+ "sort"
+ "sync"
+ "time"
+
+ "spark-server/internal/module/friend/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+type pairKey struct {
+ Small int64
+ Big int64
+}
+
+type requestKey struct {
+ From int64
+ To int64
+}
+
+type remarkKey struct {
+ UserID int64
+ FriendID int64
+}
+
+type blockKey struct {
+ UserID int64
+ BlockedID int64
+}
+
+// MemoryRepo is an in-process friend store for tests and empty-DSN bring-up.
+type MemoryRepo struct {
+ mu sync.Mutex
+ relations map[pairKey]*entity.Relation
+ requests map[int64]*entity.Request
+ byPair map[requestKey]int64
+ remarks map[remarkKey]string
+ blocks map[blockKey]time.Time
+ reports []*entity.Report
+}
+
+func NewMemoryRepo() *MemoryRepo {
+ return &MemoryRepo{
+ relations: make(map[pairKey]*entity.Relation),
+ requests: make(map[int64]*entity.Request),
+ byPair: make(map[requestKey]int64),
+ remarks: make(map[remarkKey]string),
+ blocks: make(map[blockKey]time.Time),
+ }
+}
+
+func (m *MemoryRepo) GetRelation(_ context.Context, a, b int64) (*entity.Relation, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ rel, ok := m.relations[pairOf(a, b)]
+ if !ok {
+ return nil, errs.ErrNotFound
+ }
+ cp := *rel
+ return &cp, nil
+}
+
+func (m *MemoryRepo) ListFriends(_ context.Context, userID int64, limit int) ([]*entity.Relation, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ limit = entity.ClampLimit(limit)
+ out := make([]*entity.Relation, 0)
+ for _, rel := range m.relations {
+ if rel.UserSmall == userID || rel.UserBig == userID {
+ cp := *rel
+ out = append(out, &cp)
+ }
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
+ if len(out) > limit {
+ out = out[:limit]
+ }
+ return out, nil
+}
+
+func (m *MemoryRepo) GetRequest(_ context.Context, id int64) (*entity.Request, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ req, ok := m.requests[id]
+ if !ok {
+ return nil, errs.ErrNotFound
+ }
+ cp := *req
+ return &cp, nil
+}
+
+func (m *MemoryRepo) GetRequestByPair(_ context.Context, from, to int64) (*entity.Request, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ id, ok := m.byPair[requestKey{From: from, To: to}]
+ if !ok {
+ return nil, errs.ErrNotFound
+ }
+ cp := *m.requests[id]
+ return &cp, nil
+}
+
+func (m *MemoryRepo) UpsertRequest(_ context.Context, req *entity.Request) error {
+ if req == nil || req.FromUser <= 0 || req.ToUser <= 0 {
+ return errs.ErrInvalidArgument
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ key := requestKey{From: req.FromUser, To: req.ToUser}
+ if id, ok := m.byPair[key]; ok {
+ cur := m.requests[id]
+ cur.Source = req.Source
+ cur.Reason = req.Reason
+ cur.Remark = req.Remark
+ cur.Status = req.Status
+ cur.UpdatedAt = req.UpdatedAt
+ *req = *cur
+ return nil
+ }
+ cp := *req
+ m.requests[req.ID] = &cp
+ m.byPair[key] = req.ID
+ return nil
+}
+
+func (m *MemoryRepo) ListIncoming(_ context.Context, toUserID int64, status, limit int) ([]*entity.Request, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ limit = entity.ClampLimit(limit)
+ out := make([]*entity.Request, 0)
+ for _, req := range m.requests {
+ if req.ToUser != toUserID {
+ continue
+ }
+ if status > 0 && req.Status != status {
+ continue
+ }
+ cp := *req
+ out = append(out, &cp)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt.After(out[j].UpdatedAt) })
+ if len(out) > limit {
+ out = out[:limit]
+ }
+ return out, nil
+}
+
+func pairOf(a, b int64) pairKey {
+ small, big := entity.OrderUsers(a, b)
+ return pairKey{Small: small, Big: big}
+}
diff --git a/internal/module/friend/model/memory_write.go b/internal/module/friend/model/memory_write.go
new file mode 100644
index 0000000..ce5b91c
--- /dev/null
+++ b/internal/module/friend/model/memory_write.go
@@ -0,0 +1,151 @@
+package model
+
+import (
+ "context"
+ "time"
+
+ "spark-server/internal/module/friend/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+func (m *MemoryRepo) GetRemark(_ context.Context, userID, friendID int64) (string, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return m.remarks[remarkKey{UserID: userID, FriendID: friendID}], nil
+}
+
+func (m *MemoryRepo) ListRemarks(_ context.Context, userID int64, friendIDs []int64) (map[int64]string, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ out := make(map[int64]string, len(friendIDs))
+ for _, id := range friendIDs {
+ if s := m.remarks[remarkKey{UserID: userID, FriendID: id}]; s != "" {
+ out[id] = s
+ }
+ }
+ return out, nil
+}
+
+func (m *MemoryRepo) SetRemark(_ context.Context, userID, friendID int64, remark string) error {
+ if userID <= 0 || friendID <= 0 {
+ return errs.ErrInvalidArgument
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ key := remarkKey{UserID: userID, FriendID: friendID}
+ if remark == "" {
+ delete(m.remarks, key)
+ return nil
+ }
+ m.remarks[key] = remark
+ return nil
+}
+
+func (m *MemoryRepo) IsBlocked(_ context.Context, userID, targetID int64) (bool, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ _, ok := m.blocks[blockKey{UserID: userID, BlockedID: targetID}]
+ return ok, nil
+}
+
+func (m *MemoryRepo) BlockedEither(_ context.Context, a, b int64) (bool, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ _, ab := m.blocks[blockKey{UserID: a, BlockedID: b}]
+ _, ba := m.blocks[blockKey{UserID: b, BlockedID: a}]
+ return ab || ba, nil
+}
+
+func (m *MemoryRepo) CreateBlock(_ context.Context, b *entity.Block) error {
+ if b == nil || b.UserID <= 0 || b.BlockedID <= 0 {
+ return errs.ErrInvalidArgument
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ now := b.CreatedAt
+ if now.IsZero() {
+ now = time.Now()
+ }
+ m.blocks[blockKey{UserID: b.UserID, BlockedID: b.BlockedID}] = now
+ delete(m.relations, pairOf(b.UserID, b.BlockedID))
+ delete(m.remarks, remarkKey{UserID: b.UserID, FriendID: b.BlockedID})
+ delete(m.remarks, remarkKey{UserID: b.BlockedID, FriendID: b.UserID})
+ m.rejectPendingLocked(b.UserID, b.BlockedID, now)
+ return nil
+}
+
+func (m *MemoryRepo) DeleteBlock(_ context.Context, userID, blockedID int64) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ delete(m.blocks, blockKey{UserID: userID, BlockedID: blockedID})
+ return nil
+}
+
+func (m *MemoryRepo) CreateReport(_ context.Context, r *entity.Report) error {
+ if r == nil || r.UserID <= 0 || r.TargetID <= 0 {
+ return errs.ErrInvalidArgument
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ cp := *r
+ m.reports = append(m.reports, &cp)
+ return nil
+}
+
+func (m *MemoryRepo) AcceptFriend(_ context.Context, in entity.AcceptParams) error {
+ if in.Relation == nil {
+ return errs.ErrInvalidArgument
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ now := in.Relation.CreatedAt
+ if now.IsZero() {
+ now = time.Now()
+ in.Relation.CreatedAt = now
+ }
+ key := pairKey{Small: in.Relation.UserSmall, Big: in.Relation.UserBig}
+ if _, ok := m.relations[key]; !ok {
+ cp := *in.Relation
+ m.relations[key] = &cp
+ }
+ for _, id := range in.Requests {
+ if req := m.requests[id]; req != nil {
+ req.Status = entity.RequestAccepted
+ req.UpdatedAt = now
+ }
+ }
+ for i := range in.Remarks {
+ r := in.Remarks[i]
+ if r.Text == "" {
+ continue
+ }
+ m.remarks[remarkKey{UserID: r.UserID, FriendID: r.FriendID}] = r.Text
+ }
+ return nil
+}
+
+func (m *MemoryRepo) DeleteFriend(_ context.Context, a, b int64) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ key := pairOf(a, b)
+ if _, ok := m.relations[key]; !ok {
+ return errs.ErrNotFound
+ }
+ delete(m.relations, key)
+ delete(m.remarks, remarkKey{UserID: a, FriendID: b})
+ delete(m.remarks, remarkKey{UserID: b, FriendID: a})
+ return nil
+}
+
+func (m *MemoryRepo) rejectPendingLocked(a, b int64, at time.Time) {
+ for _, req := range m.requests {
+ if req.Status != entity.RequestPending {
+ continue
+ }
+ pair := (req.FromUser == a && req.ToUser == b) || (req.FromUser == b && req.ToUser == a)
+ if pair {
+ req.Status = entity.RequestRejected
+ req.UpdatedAt = at
+ }
+ }
+}
diff --git a/internal/module/friend/model/repo.go b/internal/module/friend/model/repo.go
new file mode 100644
index 0000000..38f44a7
--- /dev/null
+++ b/internal/module/friend/model/repo.go
@@ -0,0 +1,41 @@
+package model
+
+import (
+ "context"
+ "errors"
+
+ mysqldriver "github.com/go-sql-driver/mysql"
+ "gorm.io/gorm"
+
+ "spark-server/internal/pkg/errs"
+)
+
+// Repo is MySQL persistence for friend tables.
+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("friend-mysql", err)
+}
diff --git a/internal/module/friend/model/user_block.go b/internal/module/friend/model/user_block.go
new file mode 100644
index 0000000..9ec6898
--- /dev/null
+++ b/internal/module/friend/model/user_block.go
@@ -0,0 +1,77 @@
+package model
+
+import (
+ "context"
+ "time"
+
+ "gorm.io/gorm"
+
+ "spark-server/internal/module/friend/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+// UserBlock maps table user_blocks.
+type UserBlock struct {
+ UserID int64 `gorm:"column:user_id;primaryKey;comment:拉黑人 user_id"`
+ BlockedID int64 `gorm:"column:blocked_id;primaryKey;comment:被拉黑人 user_id"`
+ CreatedAt time.Time `gorm:"column:created_at;comment:拉黑时间"`
+}
+
+func (UserBlock) TableName() string { return "user_blocks" }
+
+func (m *Repo) IsBlocked(ctx context.Context, userID, targetID int64) (bool, error) {
+ db, err := m.session(ctx)
+ if err != nil {
+ return false, err
+ }
+ var n int64
+ if err := db.Model(&UserBlock{}).Where("user_id = ? AND blocked_id = ?", userID, targetID).Count(&n).Error; err != nil {
+ return false, mapDBErr(err)
+ }
+ return n > 0, nil
+}
+
+func (m *Repo) BlockedEither(ctx context.Context, a, b int64) (bool, error) {
+ me, err := m.IsBlocked(ctx, a, b)
+ if err != nil || me {
+ return me, err
+ }
+ return m.IsBlocked(ctx, b, a)
+}
+
+func (m *Repo) CreateBlock(ctx context.Context, b *entity.Block) error {
+ if b == nil || b.UserID <= 0 || b.BlockedID <= 0 {
+ return errs.ErrInvalidArgument
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ now := b.CreatedAt
+ if now.IsZero() {
+ now = time.Now()
+ }
+ return db.Transaction(func(tx *gorm.DB) error {
+ row := UserBlock{UserID: b.UserID, BlockedID: b.BlockedID, CreatedAt: now}
+ if err := tx.Where("user_id = ? AND blocked_id = ?", b.UserID, b.BlockedID).
+ Attrs(row).FirstOrCreate(&row).Error; err != nil {
+ return mapDBErr(err)
+ }
+ small, big := entity.OrderUsers(b.UserID, b.BlockedID)
+ if err := tx.Where("user_small = ? AND user_big = ?", small, big).Delete(&FriendRelation{}).Error; err != nil {
+ return mapDBErr(err)
+ }
+ if err := deleteRemarksBetween(tx, b.UserID, b.BlockedID); err != nil {
+ return err
+ }
+ return rejectPairRequests(tx, b.UserID, b.BlockedID, now)
+ })
+}
+
+func (m *Repo) DeleteBlock(ctx context.Context, userID, blockedID int64) error {
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ return mapDBErr(db.Where("user_id = ? AND blocked_id = ?", userID, blockedID).Delete(&UserBlock{}).Error)
+}
diff --git a/internal/module/friend/service.go b/internal/module/friend/service.go
deleted file mode 100644
index 60b5b4d..0000000
--- a/internal/module/friend/service.go
+++ /dev/null
@@ -1,7 +0,0 @@
-package friend
-
-import "context"
-
-type Service interface {
- CanSend(ctx context.Context, fromUserID, toUserID int64) error
-}
diff --git a/internal/module/group/entity/collage.go b/internal/module/group/entity/collage.go
new file mode 100644
index 0000000..dfe25ad
--- /dev/null
+++ b/internal/module/group/entity/collage.go
@@ -0,0 +1,105 @@
+package entity
+
+import (
+ "image"
+ "image/color"
+ "image/draw"
+)
+
+const (
+ collageSize = 480
+ collageGap = 8
+)
+
+var (
+ collageBG = color.RGBA{R: 237, G: 237, B: 237, A: 255}
+ collageFill = color.RGBA{R: 216, G: 216, B: 216, A: 255}
+)
+
+// PlaceholderFace is a solid tile used when a member avatar cannot be loaded.
+func PlaceholderFace() *image.RGBA {
+ img := image.NewRGBA(image.Rect(0, 0, 256, 256))
+ draw.Draw(img, img.Bounds(), &image.Uniform{C: collageFill}, image.Point{}, draw.Src)
+ return img
+}
+
+// Collage tiles as many member avatars as given (1–9). Two people are two tiles, not a 3×3 grid.
+func Collage(faces []image.Image) *image.RGBA {
+ if len(faces) > MaxCollageFaces {
+ faces = faces[:MaxCollageFaces]
+ }
+ dst := image.NewRGBA(image.Rect(0, 0, collageSize, collageSize))
+ draw.Draw(dst, dst.Bounds(), &image.Uniform{C: collageBG}, image.Point{}, draw.Src)
+ cells := collageCells(len(faces))
+ for i := range cells {
+ r := cells[i]
+ src := image.Image(&image.Uniform{C: collageFill})
+ if i < len(faces) && faces[i] != nil {
+ src = faces[i]
+ }
+ blitSquare(dst, src, r)
+ }
+ return dst
+}
+
+func collageCells(n int) []image.Rectangle {
+ if n < 1 {
+ return nil
+ }
+ if n > MaxCollageFaces {
+ n = MaxCollageFaces
+ }
+ cols := 1
+ if n >= 5 {
+ cols = 3
+ } else if n >= 2 {
+ cols = 2
+ }
+ rows := (n + cols - 1) / cols
+ cell := (collageSize - collageGap*(cols+1)) / cols
+ gridW := cols*cell + collageGap*(cols-1)
+ gridH := rows*cell + collageGap*(rows-1)
+ ox := (collageSize - gridW) / 2
+ oy := (collageSize - gridH) / 2
+ first := n % cols
+ if first == 0 {
+ first = cols
+ }
+ out := make([]image.Rectangle, 0, n)
+ i := 0
+ for row := 0; row < rows && i < n; row++ {
+ count := cols
+ if row == 0 {
+ count = first
+ }
+ rowW := count*cell + collageGap*(count-1)
+ x0 := ox + (gridW-rowW)/2
+ y := oy + row*(cell+collageGap)
+ for c := 0; c < count && i < n; c++ {
+ x := x0 + c*(cell+collageGap)
+ out = append(out, image.Rect(x, y, x+cell, y+cell))
+ i++
+ }
+ }
+ return out
+}
+
+func blitSquare(dst *image.RGBA, src image.Image, r image.Rectangle) {
+ sb := src.Bounds()
+ side := sb.Dx()
+ if h := sb.Dy(); h < side {
+ side = h
+ }
+ if side < 1 || r.Empty() {
+ return
+ }
+ sx0 := sb.Min.X + (sb.Dx()-side)/2
+ sy0 := sb.Min.Y + (sb.Dy()-side)/2
+ w, h := r.Dx(), r.Dy()
+ for y := 0; y < h; y++ {
+ sy := sy0 + y*side/h
+ for x := 0; x < w; x++ {
+ dst.Set(r.Min.X+x, r.Min.Y+y, src.At(sx0+x*side/w, sy))
+ }
+ }
+}
diff --git a/internal/module/group/entity/collage_test.go b/internal/module/group/entity/collage_test.go
new file mode 100644
index 0000000..b1e4959
--- /dev/null
+++ b/internal/module/group/entity/collage_test.go
@@ -0,0 +1,67 @@
+package entity
+
+import (
+ "image"
+ "image/color"
+ "image/draw"
+ "testing"
+)
+
+func TestCollageLayout(t *testing.T) {
+ if got := collageCells(0); len(got) != 0 {
+ t.Fatalf("empty %d", len(got))
+ }
+ two := collageCells(2)
+ if len(two) != 2 {
+ t.Fatalf("two %d", len(two))
+ }
+ if two[0].Min.Y != two[1].Min.Y || two[0].Max.X >= two[1].Min.X {
+ t.Fatalf("two should sit on one row: %+v", two)
+ }
+ nine := collageCells(9)
+ if len(nine) != 9 {
+ t.Fatalf("nine %d", len(nine))
+ }
+ if two[0].Dx() <= nine[0].Dx() {
+ t.Fatalf("two-person tiles should be larger than 3x3: two=%d nine=%d", two[0].Dx(), nine[0].Dx())
+ }
+ three := collageCells(3)
+ if len(three) != 3 {
+ t.Fatalf("three %d", len(three))
+ }
+ if three[0].Min.X <= three[1].Min.X {
+ t.Fatalf("first row should be centered: %+v", three)
+ }
+}
+
+func TestCollageColors(t *testing.T) {
+ red := solid(color.RGBA{R: 255, A: 255})
+ green := solid(color.RGBA{G: 255, A: 255})
+ blue := solid(color.RGBA{B: 255, A: 255})
+ img := Collage([]image.Image{red, green, blue})
+ if img.Bounds().Dx() != collageSize {
+ t.Fatalf("size %d", img.Bounds().Dx())
+ }
+ cells := collageCells(3)
+ assertNear(t, img.At(mid(cells[0]).X, mid(cells[0]).Y), color.RGBA{R: 255, A: 255})
+ assertNear(t, img.At(mid(cells[1]).X, mid(cells[1]).Y), color.RGBA{G: 255, A: 255})
+ assertNear(t, img.At(mid(cells[2]).X, mid(cells[2]).Y), color.RGBA{B: 255, A: 255})
+}
+
+func solid(c color.RGBA) *image.RGBA {
+ img := image.NewRGBA(image.Rect(0, 0, 16, 16))
+ draw.Draw(img, img.Bounds(), &image.Uniform{C: c}, image.Point{}, draw.Src)
+ return img
+}
+
+func mid(r image.Rectangle) image.Point {
+ return image.Pt((r.Min.X+r.Max.X)/2, (r.Min.Y+r.Max.Y)/2)
+}
+
+func assertNear(t *testing.T, got color.Color, want color.RGBA) {
+ t.Helper()
+ r, g, b, a := got.RGBA()
+ if r>>8 != uint32(want.R) || g>>8 != uint32(want.G) || b>>8 != uint32(want.B) || a>>8 != uint32(want.A) {
+ t.Fatalf("got rgba %d %d %d %d want %+v", r>>8, g>>8, b>>8, a>>8, want)
+ }
+}
diff --git a/internal/module/group/entity/group.go b/internal/module/group/entity/group.go
new file mode 100644
index 0000000..2cb5a10
--- /dev/null
+++ b/internal/module/group/entity/group.go
@@ -0,0 +1,52 @@
+// Package entity holds group domain types. Persistence belongs in model.
+package entity
+
+const (
+ StatusActive = 1
+ StatusDissolved = 2
+
+ MaxNameLen = 32
+ MaxMembers = 200
+ ListDefault = 20
+ ListMax = 100
+ MemberListMax = 200
+ MaxCollageFaces = 9
+)
+
+// Group is a type-2 chat. ID is the AUTO_INCREMENT 群号; ConversationID is Snowflake.
+type Group struct {
+ ID int64
+ ConversationID int64
+ Name string
+ Avatar string
+ OwnerID int64
+ MutedAll bool
+ MemberCount int
+ Status int
+ CreatedAtMS int64
+ UpdatedAtMS int64
+}
+
+func (g *Group) Active() bool {
+ return g != nil && g.Status == StatusActive
+}
+
+func ClampLimit(limit int) int {
+ if limit <= 0 {
+ return ListDefault
+ }
+ if limit > ListMax {
+ return ListMax
+ }
+ return limit
+}
+
+func ClampMemberLimit(limit int) int {
+ if limit <= 0 {
+ return MemberListMax
+ }
+ if limit > MemberListMax {
+ return MemberListMax
+ }
+ return limit
+}
diff --git a/internal/module/group/entity/member.go b/internal/module/group/entity/member.go
new file mode 100644
index 0000000..429fe6c
--- /dev/null
+++ b/internal/module/group/entity/member.go
@@ -0,0 +1,51 @@
+package entity
+
+const (
+ RoleOwner = 1
+ RoleAdmin = 2
+ RoleMember = 3
+)
+
+// Member is a row in group_members. SpeakMuted is send-ban, not 免打扰.
+type Member struct {
+ GroupID int64
+ UserID int64
+ Role int
+ Nickname string
+ SpeakMuted bool
+ JoinedAtMS int64
+}
+
+func ValidRole(role int) bool {
+ return role == RoleOwner || role == RoleAdmin || role == RoleMember
+}
+
+func (m *Member) CanManage() bool {
+ return m != nil && (m.Role == RoleOwner || m.Role == RoleAdmin)
+}
+
+func (m *Member) CanSpeak(mutedAll bool) bool {
+ if m == nil || m.SpeakMuted {
+ return false
+ }
+ if mutedAll && m.Role == RoleMember {
+ return false
+ }
+ return true
+}
+
+func UniqueUIDs(ids []int64) []int64 {
+ seen := make(map[int64]struct{}, len(ids))
+ out := make([]int64, 0, len(ids))
+ for _, id := range ids {
+ if id <= 0 {
+ continue
+ }
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ out = append(out, id)
+ }
+ return out
+}
diff --git a/internal/module/group/entity/system.go b/internal/module/group/entity/system.go
new file mode 100644
index 0000000..791cdc2
--- /dev/null
+++ b/internal/module/group/entity/system.go
@@ -0,0 +1,10 @@
+package entity
+
+const (
+ SystemKindCreated = "group.created"
+ SystemKindInvite = "group.invite"
+ SystemKindLeave = "group.leave"
+ SystemKindKick = "group.kick"
+ SystemKindDissolve = "group.dissolve"
+ SystemKindTransfer = "group.transfer"
+)
diff --git a/internal/module/group/logic/avatar.go b/internal/module/group/logic/avatar.go
new file mode 100644
index 0000000..105826e
--- /dev/null
+++ b/internal/module/group/logic/avatar.go
@@ -0,0 +1,137 @@
+package logic
+
+import (
+ "bytes"
+ "context"
+ "image"
+ _ "image/gif" // member avatars
+ "image/jpeg"
+ _ "image/png" // member avatars
+ "strconv"
+ "strings"
+
+ "spark-server/internal/module/group/entity"
+ uploadentity "spark-server/internal/module/upload/entity"
+ userentity "spark-server/internal/module/user/entity"
+)
+
+const collageJPEG = "image/jpeg"
+
+func (s *Service) CanSetAvatar(ctx context.Context, userID, groupID int64) error {
+ if _, err := s.loadActive(ctx, groupID); err != nil {
+ return err
+ }
+ _, err := s.requireManage(ctx, groupID, userID)
+ return err
+}
+
+func validGroupAvatar(groupID int64, key string) bool {
+ key = strings.TrimSpace(key)
+ if key == "" {
+ return true
+ }
+ if groupID <= 0 || !uploadentity.ValidObjectKey(key) {
+ return false
+ }
+ return strings.HasPrefix(key, uploadentity.PrefixGroup+strconv.FormatInt(groupID, 10)+"/")
+}
+
+func (s *Service) stampAvatar(ctx context.Context, g *entity.Group, ownerID int64, invitees []int64) {
+ if s.objects == nil || g == nil || g.ID <= 0 {
+ return
+ }
+ raw, err := encodeCollage(s.memberFaces(ctx, ownerID, invitees))
+ if err == nil && s.putAvatar(ctx, g, raw, collageJPEG, "jpg") {
+ return
+ }
+ s.stampFallbackAvatar(ctx, g)
+}
+
+func (s *Service) stampFallbackAvatar(ctx context.Context, g *entity.Group) {
+ raw, err := userentity.LetterAvatarPNG(g.Name)
+ if err == nil && s.putAvatar(ctx, g, raw, "image/png", "png") {
+ return
+ }
+ raw, err = encodeCollage([]image.Image{entity.PlaceholderFace()})
+ if err != nil {
+ return
+ }
+ s.putAvatar(ctx, g, raw, collageJPEG, "jpg")
+}
+
+func (s *Service) putAvatar(ctx context.Context, g *entity.Group, raw []byte, mime, ext string) bool {
+ if g == nil || len(raw) == 0 {
+ return false
+ }
+ key := uploadentity.PrefixGroup + strconv.FormatInt(g.ID, 10) + "/default." + ext
+ if objectID, err := s.ids.NextID(); err == nil {
+ key = uploadentity.BuildObjectKey(uploadentity.PurposeGroupAvatar, g.ID, objectID, "", ext)
+ }
+ if err := s.objects.Put(ctx, key, raw, mime); err != nil {
+ return false
+ }
+ g.Avatar = key
+ if err := s.repo.UpdateProfile(ctx, g); err != nil {
+ g.Avatar = ""
+ return false
+ }
+ return true
+}
+
+func (s *Service) memberFaces(ctx context.Context, ownerID int64, invitees []int64) []image.Image {
+ uids := append([]int64{ownerID}, invitees...)
+ if len(uids) > entity.MaxCollageFaces {
+ uids = uids[:entity.MaxCollageFaces]
+ }
+ out := make([]image.Image, 0, len(uids))
+ for _, uid := range uids {
+ out = append(out, s.faceOf(ctx, uid))
+ }
+ return out
+}
+
+func (s *Service) faceOf(ctx context.Context, uid int64) image.Image {
+ u, err := s.users.Get(ctx, uid)
+ if err == nil && u != nil {
+ if img, ok := decodeAvatar(s.avatarBytes(ctx, u.Avatar)); ok {
+ return img
+ }
+ if raw, err := userentity.LetterAvatarPNG(u.Nickname); err == nil {
+ if img, ok := decodeAvatar(raw); ok {
+ return img
+ }
+ }
+ }
+ return entity.PlaceholderFace()
+}
+
+func (s *Service) avatarBytes(ctx context.Context, key string) []byte {
+ key = strings.TrimSpace(key)
+ if !uploadentity.ValidObjectKey(key) {
+ return nil
+ }
+ raw, err := s.objects.Get(ctx, key)
+ if err != nil {
+ return nil
+ }
+ return raw
+}
+
+func decodeAvatar(raw []byte) (image.Image, bool) {
+ if len(raw) == 0 {
+ return nil, false
+ }
+ img, _, err := image.Decode(bytes.NewReader(raw))
+ if err != nil {
+ return nil, false
+ }
+ return img, true
+}
+
+func encodeCollage(faces []image.Image) ([]byte, error) {
+ var buf bytes.Buffer
+ if err := jpeg.Encode(&buf, entity.Collage(faces), &jpeg.Options{Quality: 85}); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
diff --git a/internal/module/group/logic/create.go b/internal/module/group/logic/create.go
new file mode 100644
index 0000000..9090eca
--- /dev/null
+++ b/internal/module/group/logic/create.go
@@ -0,0 +1,83 @@
+package logic
+
+import (
+ "context"
+ "strings"
+
+ "spark-server/internal/module/group/entity"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
+ "spark-server/internal/pkg/reqctx"
+ "spark-server/internal/pkg/validate"
+)
+
+type CreateInput struct {
+ Name string `json:"name" validate:"required,min=1,max=32" label:"field.group_name" errmsg:"required:validate.group.name_required;min:validate.group.name_invalid;max:validate.group.name_invalid"`
+ MemberUIDs []int64 `json:"member_uids" validate:"required,min=1,max=199,dive,gt=0" label:"field.member_uids" errmsg:"required:validate.group.members_required;min:validate.group.members_required;max:validate.group.members_invalid"`
+}
+
+func (s *Service) Create(ctx context.Context, ownerID int64, in *CreateInput) (*View, error) {
+ if !s.ready() || ownerID <= 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
+ }
+ invitees, err := s.collectInvitees(ctx, ownerID, in.MemberUIDs)
+ if err != nil {
+ return nil, err
+ }
+ convID, err := s.ids.NextID()
+ if err != nil {
+ return nil, err
+ }
+ now := nowMS()
+ g := &entity.Group{
+ ConversationID: convID, Name: strings.TrimSpace(in.Name),
+ OwnerID: ownerID, MemberCount: 1 + len(invitees), Status: entity.StatusActive,
+ CreatedAtMS: now, UpdatedAtMS: now,
+ }
+ members := ownerAndInvitees(g.ID, ownerID, invitees, now)
+ if err := s.repo.CreateGroup(ctx, g, members); err != nil {
+ return nil, err
+ }
+ s.invalidateMembers(g.ConversationID)
+ s.stampAvatar(ctx, g, ownerID, invitees)
+ _ = s.notice(ctx, g, ownerID, entity.SystemKindCreated, invitees)
+ return viewOf(g, entity.RoleOwner), nil
+}
+
+func (s *Service) collectInvitees(ctx context.Context, ownerID int64, uids []int64) ([]int64, error) {
+ lang := reqctx.Lang(ctx)
+ out := make([]int64, 0, len(uids))
+ for _, uid := range entity.UniqueUIDs(uids) {
+ if uid == ownerID {
+ continue
+ }
+ if err := s.activeUser(ctx, uid); err != nil {
+ return nil, err
+ }
+ if err := s.requireFriend(ctx, ownerID, uid); err != nil {
+ return nil, err
+ }
+ out = append(out, uid)
+ }
+ if len(out) == 0 {
+ return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyGroupMembersRequired, nil))
+ }
+ if 1+len(out) > entity.MaxMembers {
+ return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyGroupFull, nil))
+ }
+ return out, nil
+}
+
+func ownerAndInvitees(groupID, ownerID int64, invitees []int64, now int64) []*entity.Member {
+ members := []*entity.Member{{GroupID: groupID, UserID: ownerID, Role: entity.RoleOwner, JoinedAtMS: now}}
+ for _, uid := range invitees {
+ members = append(members, &entity.Member{GroupID: groupID, UserID: uid, Role: entity.RoleMember, JoinedAtMS: now})
+ }
+ return members
+}
diff --git a/internal/module/group/logic/get.go b/internal/module/group/logic/get.go
new file mode 100644
index 0000000..a8fc602
--- /dev/null
+++ b/internal/module/group/logic/get.go
@@ -0,0 +1,41 @@
+package logic
+
+import (
+ "context"
+
+ "spark-server/internal/pkg/errs"
+)
+
+func (s *Service) Get(ctx context.Context, userID, groupID int64) (*View, error) {
+ if !s.ready() || userID <= 0 {
+ return nil, errs.ErrUnauthorized
+ }
+ g, err := s.loadActive(ctx, groupID)
+ if err != nil {
+ return nil, err
+ }
+ mem, err := s.requireMember(ctx, groupID, userID)
+ if err != nil {
+ return nil, err
+ }
+ return viewOf(g, mem.Role), nil
+}
+
+func (s *Service) List(ctx context.Context, userID int64, limit int) (*List, error) {
+ if !s.ready() || userID <= 0 {
+ return nil, errs.ErrUnauthorized
+ }
+ groups, err := s.repo.ListByUser(ctx, userID, limit)
+ if err != nil {
+ return nil, err
+ }
+ items := make([]*View, 0, len(groups))
+ for _, g := range groups {
+ role := 0
+ if mem, err := s.repo.GetMember(ctx, g.ID, userID); err == nil {
+ role = mem.Role
+ }
+ items = append(items, viewOf(g, role))
+ }
+ return &List{Items: items}, nil
+}
diff --git a/internal/module/group/logic/group_test.go b/internal/module/group/logic/group_test.go
new file mode 100644
index 0000000..c4408d5
--- /dev/null
+++ b/internal/module/group/logic/group_test.go
@@ -0,0 +1,269 @@
+package logic
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "image/jpeg"
+ "strconv"
+ "strings"
+ "testing"
+
+ "spark-server/internal/infrastructure/id"
+ convlogic "spark-server/internal/module/conversation/logic"
+ convmodel "spark-server/internal/module/conversation/model"
+ delivermodel "spark-server/internal/module/deliver/model"
+ friendlogic "spark-server/internal/module/friend/logic"
+ friendmodel "spark-server/internal/module/friend/model"
+ "spark-server/internal/module/group/entity"
+ groupmodel "spark-server/internal/module/group/model"
+ msgmodel "spark-server/internal/module/message/model"
+ userentity "spark-server/internal/module/user/entity"
+ userlogic "spark-server/internal/module/user/logic"
+ usermodel "spark-server/internal/module/user/model"
+ "spark-server/internal/pkg/errs"
+)
+
+func testGroups(t *testing.T) (*Service, *friendlogic.Service, *userlogic.Service) {
+ t.Helper()
+ return testGroupsStore(t, nil)
+}
+
+type testObjects interface {
+ Get(ctx context.Context, key string) ([]byte, error)
+ Put(ctx context.Context, key string, body []byte, contentType string) error
+}
+
+func testGroupsStore(t *testing.T, objects testObjects) (*Service, *friendlogic.Service, *userlogic.Service) {
+ t.Helper()
+ ids, err := id.New(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var userObjs userlogic.Objects
+ var groupObjs Objects
+ if objects != nil {
+ userObjs = objects
+ groupObjs = objects
+ }
+ users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, userObjs)
+ friends := friendlogic.NewService(
+ friendmodel.NewMemoryRepo(), users, ids,
+ friendlogic.NewGreeter(convlogic.NewP2PService(convmodel.NewMemoryP2P(), ids), msgmodel.NewMemoryRepo(), ids, delivermodel.NewMemoryQueue()),
+ )
+ groups := NewService(groupmodel.NewMemoryRepo(), users, friends, ids, msgmodel.NewMemoryRepo(), delivermodel.NewMemoryQueue(), groupObjs)
+ return groups, friends, users
+}
+
+func login(t *testing.T, users *userlogic.Service, email string, device uint64) *userentity.User {
+ t.Helper()
+ u, err := users.AuthenticateEmail(t.Context(), email, "1", &userentity.LoginDevice{DeviceID: device, DeviceType: userentity.DeviceIOS})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return u
+}
+
+func becomeFriends(t *testing.T, friends *friendlogic.Service, a, b *userentity.User) {
+ t.Helper()
+ ctx := t.Context()
+ req, err := friends.Apply(ctx, a.ID, &friendlogic.ApplyInput{UID: b.ID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := friends.Accept(ctx, b.ID, friendlogic.AcceptInput{RequestID: req.RequestID}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestCreateGroupAutoIncrementID(t *testing.T) {
+ svc, friends, users := testGroups(t)
+ ctx := t.Context()
+ a := login(t, users, "test01@spark.com", 1)
+ b := login(t, users, "test02@spark.com", 2)
+ c := login(t, users, "test03@spark.com", 3)
+ becomeFriends(t, friends, a, b)
+ becomeFriends(t, friends, a, c)
+
+ g, err := svc.Create(ctx, a.ID, &CreateInput{Name: "周末局", MemberUIDs: []int64{b.ID, c.ID}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if g.ID != 1 || g.ConversationID == g.ID || g.ConversationID <= 0 || g.Avatar != "" {
+ t.Fatalf("id=%d conv=%d avatar=%q", g.ID, g.ConversationID, g.Avatar)
+ }
+ if g.MemberCount != 3 || g.Role != entity.RoleOwner || g.OwnerID != a.ID {
+ t.Fatalf("%+v", g)
+ }
+ second, err := svc.Create(ctx, a.ID, &CreateInput{Name: "第二群", MemberUIDs: []int64{b.ID}})
+ if err != nil || second.ID != 2 {
+ t.Fatalf("second id=%d err=%v", second.ID, err)
+ }
+ if err := svc.CanSend(ctx, b.ID, g.ConversationID); err != nil {
+ t.Fatal(err)
+ }
+ ids, err := svc.MemberIDs(ctx, g.ConversationID)
+ if err != nil || len(ids) != 3 {
+ t.Fatalf("members %v %v", ids, err)
+ }
+}
+
+func TestGroupInviteKickLeaveMute(t *testing.T) {
+ svc, friends, users := testGroups(t)
+ ctx := t.Context()
+ a := login(t, users, "test01@spark.com", 1)
+ b := login(t, users, "test02@spark.com", 2)
+ c := login(t, users, "test03@spark.com", 3)
+ becomeFriends(t, friends, a, b)
+ becomeFriends(t, friends, a, c)
+ becomeFriends(t, friends, b, c)
+
+ g, err := svc.Create(ctx, a.ID, &CreateInput{Name: "局", MemberUIDs: []int64{b.ID}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := svc.Create(ctx, a.ID, &CreateInput{Name: "x", MemberUIDs: []int64{999}}); err == nil {
+ t.Fatal("missing user")
+ }
+ if _, err := svc.Invite(ctx, a.ID, g.ID, &InviteInput{UIDs: []int64{c.ID}}); err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.RemoveMember(ctx, a.ID, g.ID, a.ID); !errors.Is(err, errs.ErrForbidden) {
+ t.Fatalf("owner leave %v", err)
+ }
+ if err := svc.RemoveMember(ctx, a.ID, g.ID, c.ID); err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.CanSend(ctx, c.ID, g.ConversationID); !errors.Is(err, errs.ErrForbidden) {
+ t.Fatalf("kicked send %v", err)
+ }
+ muted := true
+ if _, err := svc.UpdateMember(ctx, a.ID, g.ID, b.ID, &UpdateMemberInput{SpeakMuted: &muted}); err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.CanSend(ctx, b.ID, g.ConversationID); !errors.Is(err, errs.ErrForbidden) {
+ t.Fatalf("muted send %v", err)
+ }
+ if err := svc.RemoveMember(ctx, b.ID, g.ID, b.ID); err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.Dissolve(ctx, a.ID, g.ID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := svc.Get(ctx, a.ID, g.ID); !errors.Is(err, errs.ErrNotFound) {
+ t.Fatalf("dissolved %v", err)
+ }
+}
+
+func TestGroupAvatarKey(t *testing.T) {
+ svc, friends, users := testGroups(t)
+ ctx := t.Context()
+ a := login(t, users, "test01@spark.com", 1)
+ b := login(t, users, "test02@spark.com", 2)
+ becomeFriends(t, friends, a, b)
+ g, err := svc.Create(ctx, a.ID, &CreateInput{Name: "局", MemberUIDs: []int64{b.ID}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ key := "perm/group/1/99.jpg"
+ if g.ID != 1 {
+ key = "perm/group/" + strconv.FormatInt(g.ID, 10) + "/99.jpg"
+ }
+ if _, err := svc.Update(ctx, a.ID, g.ID, &UpdateInput{Avatar: &key}); err != nil {
+ t.Fatal(err)
+ }
+ got, err := svc.Get(ctx, a.ID, g.ID)
+ if err != nil || got.Avatar != key {
+ t.Fatalf("%+v %v", got, err)
+ }
+ bad := "perm/avatar/1/x.jpg"
+ if _, err := svc.Update(ctx, a.ID, g.ID, &UpdateInput{Avatar: &bad}); err == nil {
+ t.Fatal("personal avatar key")
+ }
+}
+
+func TestCreateGroupCollageAvatar(t *testing.T) {
+ store := &memObjects{kv: map[string][]byte{}}
+ svc, friends, users := testGroupsStore(t, store)
+ ctx := t.Context()
+ a := login(t, users, "test01@spark.com", 1)
+ b := login(t, users, "test02@spark.com", 2)
+ becomeFriends(t, friends, a, b)
+ if a.Avatar == "" || b.Avatar == "" {
+ t.Fatal("register default avatar")
+ }
+ got, err := svc.Create(ctx, a.ID, &CreateInput{Name: "拼接头像", MemberUIDs: []int64{b.ID}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ prefix := "perm/group/" + strconv.FormatInt(got.ID, 10) + "/"
+ if !strings.HasPrefix(got.Avatar, prefix) {
+ t.Fatalf("avatar %q", got.Avatar)
+ }
+ raw, ok := store.kv[got.Avatar]
+ if !ok || len(raw) == 0 {
+ t.Fatal("missing object")
+ }
+ img, err := jpeg.Decode(bytes.NewReader(raw))
+ if err != nil || img.Bounds().Dx() < 1 {
+ t.Fatalf("jpeg %+v %v", img, err)
+ }
+}
+
+func TestCreateGroupAvatarWhenMemberGetFails(t *testing.T) {
+ store := &noGetStore{kv: map[string][]byte{}}
+ svc, friends, users := testGroupsStore(t, store)
+ ctx := t.Context()
+ a := login(t, users, "test01@spark.com", 1)
+ b := login(t, users, "test02@spark.com", 2)
+ becomeFriends(t, friends, a, b)
+ got, err := svc.Create(ctx, a.ID, &CreateInput{Name: "默认头像", MemberUIDs: []int64{b.ID}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Avatar == "" || !strings.HasPrefix(got.Avatar, "perm/group/") {
+ t.Fatalf("want default group avatar, got %q", got.Avatar)
+ }
+ if _, ok := store.kv[got.Avatar]; !ok {
+ t.Fatal("missing object")
+ }
+}
+
+type memObjects struct {
+ kv map[string][]byte
+}
+
+func (m *memObjects) Get(_ context.Context, key string) ([]byte, error) {
+ if m == nil || m.kv == nil {
+ return nil, errs.ErrNotFound
+ }
+ b, ok := m.kv[key]
+ if !ok {
+ return nil, errs.ErrNotFound
+ }
+ return append([]byte(nil), b...), nil
+}
+
+func (m *memObjects) Put(_ context.Context, key string, body []byte, _ string) error {
+ if m.kv == nil {
+ m.kv = map[string][]byte{}
+ }
+ m.kv[key] = append([]byte(nil), body...)
+ return nil
+}
+
+type noGetStore struct {
+ kv map[string][]byte
+}
+
+func (n *noGetStore) Get(_ context.Context, _ string) ([]byte, error) {
+ return nil, errs.ErrNotFound
+}
+
+func (n *noGetStore) Put(_ context.Context, key string, body []byte, _ string) error {
+ if n.kv == nil {
+ n.kv = map[string][]byte{}
+ }
+ n.kv[key] = append([]byte(nil), body...)
+ return nil
+}
diff --git a/internal/module/group/logic/invite.go b/internal/module/group/logic/invite.go
new file mode 100644
index 0000000..35db369
--- /dev/null
+++ b/internal/module/group/logic/invite.go
@@ -0,0 +1,81 @@
+package logic
+
+import (
+ "context"
+
+ "spark-server/internal/module/group/entity"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
+ "spark-server/internal/pkg/reqctx"
+ "spark-server/internal/pkg/validate"
+)
+
+type InviteInput struct {
+ UIDs []int64 `json:"uids" validate:"required,min=1,max=199,dive,gt=0" label:"field.member_uids" errmsg:"required:validate.group.members_required;min:validate.group.members_required;max:validate.group.members_invalid"`
+}
+
+func (s *Service) Invite(ctx context.Context, actorID, groupID int64, in *InviteInput) (*View, error) {
+ if !s.ready() || actorID <= 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
+ }
+ g, err := s.loadActive(ctx, groupID)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := s.requireMember(ctx, groupID, actorID); err != nil {
+ return nil, err
+ }
+ added, err := s.newMembers(ctx, g, actorID, in.UIDs)
+ if err != nil {
+ return nil, err
+ }
+ if err := s.repo.AddMembers(ctx, groupID, added); err != nil {
+ return nil, err
+ }
+ g.MemberCount += len(added)
+ s.invalidateMembers(g.ConversationID)
+ ids := make([]int64, 0, len(added))
+ for _, mem := range added {
+ ids = append(ids, mem.UserID)
+ }
+ _ = s.notice(ctx, g, actorID, entity.SystemKindInvite, ids)
+ mem, err := s.requireMember(ctx, groupID, actorID)
+ if err != nil {
+ return viewOf(g, entity.RoleMember), nil
+ }
+ return viewOf(g, mem.Role), nil
+}
+
+func (s *Service) newMembers(ctx context.Context, g *entity.Group, actorID int64, uids []int64) ([]*entity.Member, error) {
+ lang := reqctx.Lang(ctx)
+ now := nowMS()
+ added := make([]*entity.Member, 0, len(uids))
+ for _, uid := range entity.UniqueUIDs(uids) {
+ if uid == actorID {
+ continue
+ }
+ if _, err := s.repo.GetMember(ctx, g.ID, uid); err == nil {
+ continue
+ }
+ if err := s.activeUser(ctx, uid); err != nil {
+ return nil, err
+ }
+ if err := s.requireFriend(ctx, actorID, uid); err != nil {
+ return nil, err
+ }
+ added = append(added, &entity.Member{GroupID: g.ID, UserID: uid, Role: entity.RoleMember, JoinedAtMS: now})
+ }
+ if len(added) == 0 {
+ return nil, errs.ErrDuplicate.WithMsg(i18n.T(lang, i18n.KeyGroupAlreadyMember, nil))
+ }
+ if g.MemberCount+len(added) > entity.MaxMembers {
+ return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyGroupFull, nil))
+ }
+ return added, nil
+}
diff --git a/internal/module/group/logic/lookup.go b/internal/module/group/logic/lookup.go
new file mode 100644
index 0000000..a06c885
--- /dev/null
+++ b/internal/module/group/logic/lookup.go
@@ -0,0 +1,74 @@
+package logic
+
+import (
+ "context"
+ "errors"
+
+ "spark-server/internal/module/group/entity"
+ userentity "spark-server/internal/module/user/entity"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
+ "spark-server/internal/pkg/reqctx"
+)
+
+func (s *Service) activeUser(ctx context.Context, id int64) error {
+ u, err := s.users.Get(ctx, id)
+ if err != nil {
+ return err
+ }
+ if u == nil || u.Status != userentity.StatusActive {
+ return errs.ErrNotFound
+ }
+ return nil
+}
+
+func (s *Service) loadActive(ctx context.Context, groupID int64) (*entity.Group, error) {
+ g, err := s.repo.Get(ctx, groupID)
+ if err != nil {
+ return nil, err
+ }
+ if !g.Active() {
+ return nil, errs.ErrNotFound.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyGroupDissolved, nil))
+ }
+ return g, nil
+}
+
+func (s *Service) requireMember(ctx context.Context, groupID, userID int64) (*entity.Member, error) {
+ mem, err := s.repo.GetMember(ctx, groupID, userID)
+ if err != nil {
+ if errors.Is(err, errs.ErrNotFound) {
+ return nil, errs.ErrForbidden.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyGroupNotMember, nil))
+ }
+ return nil, err
+ }
+ return mem, nil
+}
+
+func (s *Service) requireManage(ctx context.Context, groupID, userID int64) (*entity.Member, error) {
+ mem, err := s.requireMember(ctx, groupID, userID)
+ if err != nil {
+ return nil, err
+ }
+ if !mem.CanManage() {
+ return nil, errs.ErrForbidden.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyGroupRoleDenied, nil))
+ }
+ return mem, nil
+}
+
+func (s *Service) requireOwner(ctx context.Context, g *entity.Group, userID int64) error {
+ if g == nil || g.OwnerID != userID {
+ return errs.ErrForbidden.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyGroupRoleDenied, nil))
+ }
+ return nil
+}
+
+func (s *Service) requireFriend(ctx context.Context, a, b int64) error {
+ ok, err := s.friends.AreFriends(ctx, a, b)
+ if err != nil {
+ return err
+ }
+ if !ok {
+ return errs.ErrForbidden.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyGroupNotFriend, nil))
+ }
+ return nil
+}
diff --git a/internal/module/group/logic/member_update.go b/internal/module/group/logic/member_update.go
new file mode 100644
index 0000000..e7af455
--- /dev/null
+++ b/internal/module/group/logic/member_update.go
@@ -0,0 +1,81 @@
+package logic
+
+import (
+ "context"
+ "strings"
+
+ "spark-server/internal/module/group/entity"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
+ "spark-server/internal/pkg/reqctx"
+ "spark-server/internal/pkg/validate"
+)
+
+type UpdateMemberInput struct {
+ Role *int `json:"role" validate:"omitempty,oneof=2 3" label:"field.role" errmsg:"oneof:validate.group.role_invalid"`
+ SpeakMuted *bool `json:"speak_muted"`
+ Nickname *string `json:"nickname" validate:"omitempty,max=32" label:"field.group_nickname" errmsg:"max:validate.group.nickname_invalid"`
+}
+
+func (s *Service) UpdateMember(ctx context.Context, actorID, groupID, targetID int64, in *UpdateMemberInput) (*MemberView, error) {
+ if !s.ready() || actorID <= 0 {
+ return nil, errs.ErrUnauthorized
+ }
+ if in == nil || targetID <= 0 {
+ return nil, errs.ErrInvalidArgument
+ }
+ if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
+ return nil, err
+ }
+ if in.Role == nil && in.SpeakMuted == nil && in.Nickname == nil {
+ return nil, errs.ErrInvalidArgument
+ }
+ g, err := s.loadActive(ctx, groupID)
+ if err != nil {
+ return nil, err
+ }
+ target, err := s.requireMember(ctx, groupID, targetID)
+ if err != nil {
+ return nil, err
+ }
+ if err := s.applyMemberPatch(ctx, g, actorID, target, in); err != nil {
+ return nil, err
+ }
+ if err := s.repo.UpdateMember(ctx, target); err != nil {
+ return nil, err
+ }
+ u, _ := s.users.Get(ctx, target.UserID)
+ return memberViewOf(target, u), nil
+}
+
+func (s *Service) applyMemberPatch(ctx context.Context, g *entity.Group, actorID int64, target *entity.Member, in *UpdateMemberInput) error {
+ lang := reqctx.Lang(ctx)
+ self := actorID == target.UserID
+ if in.Nickname != nil {
+ if !self {
+ if _, err := s.requireManage(ctx, g.ID, actorID); err != nil {
+ return err
+ }
+ }
+ target.Nickname = strings.TrimSpace(*in.Nickname)
+ }
+ if in.Role != nil {
+ if err := s.requireOwner(ctx, g, actorID); err != nil {
+ return err
+ }
+ if self || (*in.Role != entity.RoleAdmin && *in.Role != entity.RoleMember) {
+ return errs.ErrForbidden.WithMsg(i18n.T(lang, i18n.KeyGroupRoleDenied, nil))
+ }
+ target.Role = *in.Role
+ }
+ if in.SpeakMuted != nil {
+ if _, err := s.requireManage(ctx, g.ID, actorID); err != nil {
+ return err
+ }
+ if target.Role == entity.RoleOwner {
+ return errs.ErrForbidden.WithMsg(i18n.T(lang, i18n.KeyGroupRoleDenied, nil))
+ }
+ target.SpeakMuted = *in.SpeakMuted
+ }
+ return nil
+}
diff --git a/internal/module/group/logic/members.go b/internal/module/group/logic/members.go
new file mode 100644
index 0000000..0a0b77a
--- /dev/null
+++ b/internal/module/group/logic/members.go
@@ -0,0 +1,73 @@
+package logic
+
+import (
+ "context"
+
+ "spark-server/internal/pkg/errs"
+)
+
+func (s *Service) ListMembers(ctx context.Context, userID, groupID int64, limit int) (*MemberList, error) {
+ if !s.ready() || userID <= 0 {
+ return nil, errs.ErrUnauthorized
+ }
+ if _, err := s.loadActive(ctx, groupID); err != nil {
+ return nil, err
+ }
+ if _, err := s.requireMember(ctx, groupID, userID); err != nil {
+ return nil, err
+ }
+ rows, err := s.repo.ListMembers(ctx, groupID, limit)
+ if err != nil {
+ return nil, err
+ }
+ items := make([]*MemberView, 0, len(rows))
+ for _, mem := range rows {
+ u, err := s.users.Get(ctx, mem.UserID)
+ if err != nil {
+ items = append(items, memberViewOf(mem, nil))
+ continue
+ }
+ items = append(items, memberViewOf(mem, u))
+ }
+ return &MemberList{Items: items}, nil
+}
+
+func (s *Service) MemberIDs(ctx context.Context, conversationID int64) ([]int64, error) {
+ if !s.ready() || conversationID <= 0 {
+ return nil, errs.ErrInvalidArgument
+ }
+ if ids, ok := s.cachedMemberIDs(conversationID); ok {
+ return ids, nil
+ }
+ g, err := s.repo.GetByConversation(ctx, conversationID)
+ if err != nil {
+ return nil, err
+ }
+ if !g.Active() {
+ return []int64{}, nil
+ }
+ ids, err := s.repo.ListMemberIDs(ctx, g.ID)
+ if err != nil {
+ return nil, err
+ }
+ s.putMemberIDs(conversationID, ids)
+ return ids, nil
+}
+
+func (s *Service) CanSend(ctx context.Context, userID, conversationID int64) error {
+ if !s.ready() || userID <= 0 || conversationID <= 0 {
+ return errs.ErrForbidden
+ }
+ g, err := s.repo.GetByConversation(ctx, conversationID)
+ if err != nil || !g.Active() {
+ return errs.ErrForbidden
+ }
+ mem, err := s.repo.GetMember(ctx, g.ID, userID)
+ if err != nil {
+ return errs.ErrForbidden
+ }
+ if !mem.CanSpeak(g.MutedAll) {
+ return errs.ErrForbidden
+ }
+ return nil
+}
diff --git a/internal/module/group/logic/notice.go b/internal/module/group/logic/notice.go
new file mode 100644
index 0000000..0acf6a7
--- /dev/null
+++ b/internal/module/group/logic/notice.go
@@ -0,0 +1,65 @@
+package logic
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ deliverentity "spark-server/internal/module/deliver/entity"
+ "spark-server/internal/module/group/entity"
+ msgentity "spark-server/internal/module/message/entity"
+ "spark-server/internal/pkg/reply"
+)
+
+func (s *Service) notice(ctx context.Context, g *entity.Group, actorID int64, kind string, targets []int64) error {
+ if s.msgs == nil || s.ids == nil || g == nil || g.ConversationID <= 0 {
+ return nil
+ }
+ msgID, err := s.ids.NextID()
+ if err != nil {
+ return err
+ }
+ now := time.Now()
+ content := msgentity.NewSystem(&msgentity.System{Kind: kind})
+ raw, err := content.Marshal()
+ if err != nil {
+ return err
+ }
+ msg := &msgentity.Message{
+ ID: msgID, ConversationID: g.ConversationID, SenderID: 0,
+ ClientMsgID: fmt.Sprintf("sys:group:%d", msgID),
+ Type: msgentity.TypeSystem, Content: raw,
+ Status: msgentity.StatusNormal, CreatedAtMS: now.UnixMilli(),
+ }
+ if err := s.msgs.Create(ctx, msg); err != nil {
+ return err
+ }
+ if err := s.repo.UpdateLastMsg(ctx, g.ConversationID, msgID, now.UnixMilli()); err != nil {
+ return err
+ }
+ if s.queue == nil {
+ return nil
+ }
+ to, err := s.repo.ListMemberIDs(ctx, g.ID)
+ if err != nil || len(to) == 0 {
+ to = append([]int64{actorID}, targets...)
+ }
+ return s.queue.Push(ctx, &deliverentity.Job{
+ MsgID: msgID, Cmd: cmdMessage, ConversationID: g.ConversationID,
+ FromUserID: actorID, ToUserIDs: to,
+ Payload: reply.MustMarshal(reply.OK(groupPushBody{
+ MsgID: msgID, ConversationID: g.ConversationID, SenderID: 0,
+ MessageType: int16(msgentity.TypeSystem), Content: raw, CreatedAt: now.UnixMilli(),
+ })),
+ })
+}
+
+type groupPushBody struct {
+ MsgID int64 `json:"msg_id"`
+ ConversationID int64 `json:"conversation_id"`
+ SenderID int64 `json:"sender_id"`
+ MessageType int16 `json:"message_type"`
+ Content json.RawMessage `json:"content"`
+ CreatedAt int64 `json:"created_at"`
+}
diff --git a/internal/module/group/logic/remove.go b/internal/module/group/logic/remove.go
new file mode 100644
index 0000000..e8e440f
--- /dev/null
+++ b/internal/module/group/logic/remove.go
@@ -0,0 +1,59 @@
+package logic
+
+import (
+ "context"
+
+ "spark-server/internal/module/group/entity"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
+ "spark-server/internal/pkg/reqctx"
+)
+
+func (s *Service) RemoveMember(ctx context.Context, actorID, groupID, targetID int64) error {
+ if !s.ready() || actorID <= 0 {
+ return errs.ErrUnauthorized
+ }
+ if targetID <= 0 {
+ return errs.ErrInvalidArgument
+ }
+ g, err := s.loadActive(ctx, groupID)
+ if err != nil {
+ return err
+ }
+ target, err := s.repo.GetMember(ctx, groupID, targetID)
+ if err != nil {
+ return err
+ }
+ if actorID == targetID {
+ return s.leave(ctx, g, target)
+ }
+ return s.kick(ctx, g, actorID, target)
+}
+
+func (s *Service) leave(ctx context.Context, g *entity.Group, self *entity.Member) error {
+ if self.Role == entity.RoleOwner {
+ return errs.ErrForbidden.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyGroupOwnerLeave, nil))
+ }
+ if err := s.repo.RemoveMember(ctx, g.ID, self.UserID); err != nil {
+ return err
+ }
+ s.invalidateMembers(g.ConversationID)
+ _ = s.notice(ctx, g, self.UserID, entity.SystemKindLeave, []int64{self.UserID})
+ return nil
+}
+
+func (s *Service) kick(ctx context.Context, g *entity.Group, actorID int64, target *entity.Member) error {
+ actor, err := s.requireManage(ctx, g.ID, actorID)
+ if err != nil {
+ return err
+ }
+ if target.Role == entity.RoleOwner || (target.Role == entity.RoleAdmin && actor.Role != entity.RoleOwner) {
+ return errs.ErrForbidden.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyGroupRoleDenied, nil))
+ }
+ if err := s.repo.RemoveMember(ctx, g.ID, target.UserID); err != nil {
+ return err
+ }
+ s.invalidateMembers(g.ConversationID)
+ _ = s.notice(ctx, g, actorID, entity.SystemKindKick, []int64{target.UserID})
+ return nil
+}
diff --git a/internal/module/group/logic/service.go b/internal/module/group/logic/service.go
new file mode 100644
index 0000000..97da5b1
--- /dev/null
+++ b/internal/module/group/logic/service.go
@@ -0,0 +1,105 @@
+package logic
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ deliverentity "spark-server/internal/module/deliver/entity"
+ "spark-server/internal/module/group/entity"
+ msgentity "spark-server/internal/module/message/entity"
+ userentity "spark-server/internal/module/user/entity"
+)
+
+const memberCacheTTL = 3 * time.Second
+
+const cmdMessage uint16 = 6
+
+// Repository is group persistence. Model implements it; do not split this by table.
+type Repository interface {
+ CreateGroup(ctx context.Context, g *entity.Group, members []*entity.Member) error
+ Get(ctx context.Context, id int64) (*entity.Group, error)
+ GetByConversation(ctx context.Context, conversationID int64) (*entity.Group, error)
+ ListByUser(ctx context.Context, userID int64, limit int) ([]*entity.Group, error)
+ UpdateProfile(ctx context.Context, g *entity.Group) error
+ GetMember(ctx context.Context, groupID, userID int64) (*entity.Member, error)
+ ListMembers(ctx context.Context, groupID int64, limit int) ([]*entity.Member, error)
+ ListMemberIDs(ctx context.Context, groupID int64) ([]int64, error)
+ AddMembers(ctx context.Context, groupID int64, members []*entity.Member) error
+ RemoveMember(ctx context.Context, groupID, userID int64) error
+ UpdateMember(ctx context.Context, mem *entity.Member) error
+ TransferOwner(ctx context.Context, groupID, fromID, toID int64) error
+ Dissolve(ctx context.Context, groupID int64) error
+ UpdateLastMsg(ctx context.Context, conversationID, msgID, atMS int64) error
+}
+
+type Users interface {
+ Get(ctx context.Context, id int64) (*userentity.User, error)
+}
+
+type Friendships interface {
+ AreFriends(ctx context.Context, a, b int64) (bool, error)
+}
+
+type IDGen interface {
+ NextID() (int64, error)
+}
+
+type Messages interface {
+ Create(ctx context.Context, m *msgentity.Message) error
+}
+
+type DeliverQueue interface {
+ Push(ctx context.Context, job *deliverentity.Job) error
+}
+
+type Objects interface {
+ Get(ctx context.Context, key string) ([]byte, error)
+ Put(ctx context.Context, key string, body []byte, contentType string) error
+}
+
+type memberCacheEntry struct {
+ ids []int64
+ at time.Time
+}
+
+// Service is the group use-case layer (HTTP and TCP share it).
+type Service struct {
+ repo Repository
+ users Users
+ friends Friendships
+ ids IDGen
+ msgs Messages
+ queue DeliverQueue
+ objects Objects
+ cache sync.Map
+}
+
+func NewService(repo Repository, users Users, friends Friendships, ids IDGen, msgs Messages, queue DeliverQueue, objects Objects) *Service {
+ return &Service{repo: repo, users: users, friends: friends, ids: ids, msgs: msgs, queue: queue, objects: objects}
+}
+
+func (s *Service) ready() bool {
+ return s != nil && s.repo != nil && s.users != nil && s.friends != nil && s.ids != nil
+}
+
+func (s *Service) cachedMemberIDs(groupID int64) ([]int64, bool) {
+ v, ok := s.cache.Load(groupID)
+ if !ok {
+ return nil, false
+ }
+ e, ok := v.(memberCacheEntry)
+ if !ok || time.Since(e.at) > memberCacheTTL {
+ s.cache.Delete(groupID)
+ return nil, false
+ }
+ return append([]int64(nil), e.ids...), true
+}
+
+func (s *Service) putMemberIDs(groupID int64, ids []int64) {
+ s.cache.Store(groupID, memberCacheEntry{ids: append([]int64(nil), ids...), at: time.Now()})
+}
+
+func (s *Service) invalidateMembers(groupID int64) {
+ s.cache.Delete(groupID)
+}
diff --git a/internal/module/group/logic/transfer.go b/internal/module/group/logic/transfer.go
new file mode 100644
index 0000000..939e9d2
--- /dev/null
+++ b/internal/module/group/logic/transfer.go
@@ -0,0 +1,66 @@
+package logic
+
+import (
+ "context"
+
+ "spark-server/internal/module/group/entity"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/reqctx"
+ "spark-server/internal/pkg/validate"
+)
+
+type TransferInput struct {
+ UID int64 `json:"uid" validate:"required,gt=0" label:"field.uid" errmsg:"required:validate.group.uid_required;gt:validate.group.uid_required"`
+}
+
+func (s *Service) Transfer(ctx context.Context, ownerID, groupID int64, in *TransferInput) (*View, error) {
+ if !s.ready() || ownerID <= 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
+ }
+ g, err := s.loadActive(ctx, groupID)
+ if err != nil {
+ return nil, err
+ }
+ if err := s.requireOwner(ctx, g, ownerID); err != nil {
+ return nil, err
+ }
+ if in.UID == ownerID {
+ return nil, errs.ErrInvalidArgument
+ }
+ if _, err := s.requireMember(ctx, groupID, in.UID); err != nil {
+ return nil, err
+ }
+ if err := s.repo.TransferOwner(ctx, groupID, ownerID, in.UID); err != nil {
+ return nil, err
+ }
+ g.OwnerID = in.UID
+ s.invalidateMembers(g.ConversationID)
+ _ = s.notice(ctx, g, ownerID, entity.SystemKindTransfer, []int64{in.UID})
+ return viewOf(g, entity.RoleMember), nil
+}
+
+func (s *Service) Dissolve(ctx context.Context, ownerID, groupID int64) error {
+ if !s.ready() || ownerID <= 0 {
+ return errs.ErrUnauthorized
+ }
+ g, err := s.loadActive(ctx, groupID)
+ if err != nil {
+ return err
+ }
+ if err := s.requireOwner(ctx, g, ownerID); err != nil {
+ return err
+ }
+ ids, _ := s.repo.ListMemberIDs(ctx, groupID)
+ if err := s.repo.Dissolve(ctx, groupID); err != nil {
+ return err
+ }
+ s.invalidateMembers(g.ConversationID)
+ _ = s.notice(ctx, g, ownerID, entity.SystemKindDissolve, ids)
+ return nil
+}
diff --git a/internal/module/group/logic/update.go b/internal/module/group/logic/update.go
new file mode 100644
index 0000000..6c21c74
--- /dev/null
+++ b/internal/module/group/logic/update.go
@@ -0,0 +1,60 @@
+package logic
+
+import (
+ "context"
+ "strings"
+
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/reqctx"
+ "spark-server/internal/pkg/validate"
+)
+
+type UpdateInput struct {
+ Name *string `json:"name" validate:"omitempty,min=1,max=32" label:"field.group_name" errmsg:"min:validate.group.name_invalid;max:validate.group.name_invalid"`
+ Avatar *string `json:"avatar" validate:"omitempty,max=512" label:"field.avatar"`
+ MutedAll *bool `json:"muted_all"`
+}
+
+func (s *Service) Update(ctx context.Context, userID, groupID int64, in *UpdateInput) (*View, 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 in.Name == nil && in.Avatar == nil && in.MutedAll == nil {
+ return nil, errs.ErrInvalidArgument
+ }
+ g, err := s.loadActive(ctx, groupID)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := s.requireManage(ctx, groupID, userID); err != nil {
+ return nil, err
+ }
+ if in.Name != nil {
+ g.Name = strings.TrimSpace(*in.Name)
+ }
+ if in.Avatar != nil {
+ av := strings.TrimSpace(*in.Avatar)
+ if !validGroupAvatar(groupID, av) {
+ return nil, errs.ErrInvalidArgument
+ }
+ g.Avatar = av
+ }
+ if in.MutedAll != nil {
+ g.MutedAll = *in.MutedAll
+ }
+ g.UpdatedAtMS = nowMS()
+ if err := s.repo.UpdateProfile(ctx, g); err != nil {
+ return nil, err
+ }
+ mem, err := s.requireMember(ctx, groupID, userID)
+ if err != nil {
+ return nil, err
+ }
+ return viewOf(g, mem.Role), nil
+}
diff --git a/internal/module/group/logic/view.go b/internal/module/group/logic/view.go
new file mode 100644
index 0000000..2a2df18
--- /dev/null
+++ b/internal/module/group/logic/view.go
@@ -0,0 +1,75 @@
+package logic
+
+import (
+ "time"
+
+ "spark-server/internal/module/group/entity"
+ userentity "spark-server/internal/module/user/entity"
+)
+
+// View is the HTTP/TCP group card. ID is the AUTO_INCREMENT 群号.
+type View struct {
+ ID int64 `json:"id"`
+ ConversationID int64 `json:"conversation_id"`
+ Name string `json:"name"`
+ Avatar string `json:"avatar"`
+ OwnerID int64 `json:"owner_id"`
+ MutedAll bool `json:"muted_all"`
+ MemberCount int `json:"member_count"`
+ Role int `json:"role"`
+ CreatedAt int64 `json:"created_at"`
+}
+
+type MemberView struct {
+ UID int64 `json:"uid"`
+ SparkID string `json:"spark_id"`
+ Nickname string `json:"nickname"`
+ Avatar string `json:"avatar"`
+ Gender int `json:"gender"`
+ Role int `json:"role"`
+ GroupNickname string `json:"group_nickname"`
+ SpeakMuted bool `json:"speak_muted"`
+ JoinedAt int64 `json:"joined_at"`
+}
+
+type List struct {
+ Items []*View `json:"items"`
+}
+
+type MemberList struct {
+ Items []*MemberView `json:"items"`
+}
+
+func viewOf(g *entity.Group, role int) *View {
+ if g == nil {
+ return nil
+ }
+ return &View{
+ ID: g.ID, ConversationID: g.ConversationID, Name: g.Name, Avatar: g.Avatar,
+ OwnerID: g.OwnerID, MutedAll: g.MutedAll, MemberCount: g.MemberCount,
+ Role: role, CreatedAt: g.CreatedAtMS,
+ }
+}
+
+func memberViewOf(mem *entity.Member, u *userentity.User) *MemberView {
+ v := &MemberView{}
+ if mem != nil {
+ v.UID = mem.UserID
+ v.Role = mem.Role
+ v.GroupNickname = mem.Nickname
+ v.SpeakMuted = mem.SpeakMuted
+ v.JoinedAt = mem.JoinedAtMS
+ }
+ if u != nil {
+ v.SparkID = u.SparkID
+ v.Nickname = u.Nickname
+ v.Avatar = u.Avatar
+ v.Gender = u.Gender
+ if v.UID == 0 {
+ v.UID = u.ID
+ }
+ }
+ return v
+}
+
+func nowMS() int64 { return time.Now().UnixMilli() }
diff --git a/internal/module/group/model/group.go b/internal/module/group/model/group.go
new file mode 100644
index 0000000..ef6e347
--- /dev/null
+++ b/internal/module/group/model/group.go
@@ -0,0 +1,167 @@
+package model
+
+import (
+ "context"
+ "time"
+
+ "spark-server/internal/module/group/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+// Group maps table groups.
+type Group struct {
+ ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:群号,表内自增,对外群 ID"`
+ ConversationID int64 `gorm:"column:conversation_id;uniqueIndex;comment:对应 conversations.id(Snowflake),发消息与同步用"`
+ Name string `gorm:"column:name;comment:群名称"`
+ Avatar string `gorm:"column:avatar;comment:群头像对象存储 key,空表示未设置"`
+ OwnerID int64 `gorm:"column:owner_id;comment:群主 user_id"`
+ MutedAll int `gorm:"column:muted_all;comment:0=未全员禁言 1=全员禁言(群主/管理员仍可发言)"`
+ MemberCount int `gorm:"column:member_count;comment:当前成员数,含群主"`
+ Status int `gorm:"column:status;comment:1=正常 2=已解散"`
+ CreatedAt time.Time `gorm:"column:created_at;comment:创建时间"`
+ UpdatedAt time.Time `gorm:"column:updated_at;comment:资料或成员变更时间"`
+}
+
+func (Group) TableName() string { return "groups" }
+
+func (r *Group) entity() *entity.Group {
+ if r == nil {
+ return nil
+ }
+ g := &entity.Group{
+ ID: r.ID, ConversationID: r.ConversationID, Name: r.Name, Avatar: r.Avatar, OwnerID: r.OwnerID,
+ MemberCount: r.MemberCount, Status: r.Status,
+ CreatedAtMS: r.CreatedAt.UnixMilli(), UpdatedAtMS: r.UpdatedAt.UnixMilli(),
+ }
+ g.MutedAll = r.MutedAll != 0
+ return g
+}
+
+func groupRow(g *entity.Group) *Group {
+ now := msTime(g.CreatedAtMS)
+ updated := msTime(g.UpdatedAtMS)
+ if g.UpdatedAtMS <= 0 {
+ updated = now
+ }
+ row := &Group{
+ ID: g.ID, ConversationID: g.ConversationID, Name: g.Name, Avatar: g.Avatar, OwnerID: g.OwnerID,
+ MemberCount: g.MemberCount, Status: g.Status,
+ CreatedAt: now, UpdatedAt: updated,
+ }
+ if g.MutedAll {
+ row.MutedAll = 1
+ }
+ if row.Status == 0 {
+ row.Status = entity.StatusActive
+ }
+ return row
+}
+
+func (m *Repo) Get(ctx context.Context, id int64) (*entity.Group, error) {
+ if id <= 0 {
+ return nil, errs.ErrInvalidArgument
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var row Group
+ if err := db.Where("id = ?", id).Take(&row).Error; err != nil {
+ return nil, mapDBErr(err)
+ }
+ return row.entity(), nil
+}
+
+func (m *Repo) GetByConversation(ctx context.Context, conversationID int64) (*entity.Group, error) {
+ if conversationID <= 0 {
+ return nil, errs.ErrInvalidArgument
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var row Group
+ if err := db.Where("conversation_id = ?", conversationID).Take(&row).Error; err != nil {
+ return nil, mapDBErr(err)
+ }
+ return row.entity(), nil
+}
+
+func (m *Repo) ListByUser(ctx context.Context, userID int64, limit int) ([]*entity.Group, error) {
+ if userID <= 0 {
+ return nil, errs.ErrInvalidArgument
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return nil, err
+ }
+ limit = entity.ClampLimit(limit)
+ var rows []Group
+ err = db.Table("groups AS g").Select("g.*").
+ Joins("INNER JOIN group_members m ON m.group_id = g.id").
+ Where("m.user_id = ? AND g.status = ?", userID, entity.StatusActive).
+ Order("g.updated_at DESC").Limit(limit).Find(&rows).Error
+ if err != nil {
+ return nil, mapDBErr(err)
+ }
+ out := make([]*entity.Group, 0, len(rows))
+ for i := range rows {
+ out = append(out, rows[i].entity())
+ }
+ return out, nil
+}
+
+func (m *Repo) UpdateProfile(ctx context.Context, g *entity.Group) error {
+ if g == nil || g.ID <= 0 {
+ return errs.ErrInvalidArgument
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ res := db.Model(&Group{}).Where("id = ? AND status = ?", g.ID, entity.StatusActive).Updates(map[string]any{
+ "name": g.Name,
+ "avatar": g.Avatar,
+ "muted_all": boolInt(g.MutedAll),
+ "updated_at": msTime(g.UpdatedAtMS),
+ })
+ if res.Error != nil {
+ return mapDBErr(res.Error)
+ }
+ if res.RowsAffected == 0 {
+ return errs.ErrNotFound
+ }
+ return nil
+}
+
+func (m *Repo) UpdateLastMsg(ctx context.Context, conversationID, msgID, atMS int64) error {
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ res := db.Table("conversations").Where("id = ?", conversationID).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 msTime(ms int64) time.Time {
+ if ms <= 0 {
+ return time.Now()
+ }
+ return time.UnixMilli(ms)
+}
+
+func boolInt(v bool) int {
+ if v {
+ return 1
+ }
+ return 0
+}
diff --git a/internal/module/group/model/group_member.go b/internal/module/group/model/group_member.go
new file mode 100644
index 0000000..c031004
--- /dev/null
+++ b/internal/module/group/model/group_member.go
@@ -0,0 +1,121 @@
+package model
+
+import (
+ "context"
+ "time"
+
+ "spark-server/internal/module/group/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+// GroupMember maps table group_members.
+type GroupMember struct {
+ GroupID int64 `gorm:"column:group_id;primaryKey;comment:群号,groups.id"`
+ UserID int64 `gorm:"column:user_id;primaryKey;comment:成员 user_id"`
+ Role int `gorm:"column:role;comment:1=群主 2=管理员 3=普通成员"`
+ Nickname string `gorm:"column:nickname;comment:群内昵称,空则用用户昵称"`
+ SpeakMuted int `gorm:"column:speak_muted;comment:0=未禁言 1=被单独禁言"`
+ JoinedAt time.Time `gorm:"column:joined_at;comment:入群时间"`
+}
+
+func (GroupMember) TableName() string { return "group_members" }
+
+func (r *GroupMember) entity() *entity.Member {
+ if r == nil {
+ return nil
+ }
+ m := &entity.Member{
+ GroupID: r.GroupID, UserID: r.UserID, Role: r.Role,
+ Nickname: r.Nickname, JoinedAtMS: r.JoinedAt.UnixMilli(),
+ }
+ m.SpeakMuted = r.SpeakMuted != 0
+ return m
+}
+
+func memberRow(m *entity.Member) *GroupMember {
+ row := &GroupMember{
+ GroupID: m.GroupID, UserID: m.UserID, Role: m.Role,
+ Nickname: m.Nickname, JoinedAt: msTime(m.JoinedAtMS),
+ }
+ if m.SpeakMuted {
+ row.SpeakMuted = 1
+ }
+ if row.Role == 0 {
+ row.Role = entity.RoleMember
+ }
+ return row
+}
+
+func (m *Repo) GetMember(ctx context.Context, groupID, userID int64) (*entity.Member, error) {
+ if groupID <= 0 || userID <= 0 {
+ return nil, errs.ErrInvalidArgument
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var row GroupMember
+ if err := db.Where("group_id = ? AND user_id = ?", groupID, userID).Take(&row).Error; err != nil {
+ return nil, mapDBErr(err)
+ }
+ return row.entity(), nil
+}
+
+func (m *Repo) ListMembers(ctx context.Context, groupID int64, limit int) ([]*entity.Member, error) {
+ if groupID <= 0 {
+ return nil, errs.ErrInvalidArgument
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return nil, err
+ }
+ limit = entity.ClampMemberLimit(limit)
+ var rows []GroupMember
+ err = db.Where("group_id = ?", groupID).Order("role ASC, joined_at ASC").Limit(limit).Find(&rows).Error
+ if err != nil {
+ return nil, mapDBErr(err)
+ }
+ out := make([]*entity.Member, 0, len(rows))
+ for i := range rows {
+ out = append(out, rows[i].entity())
+ }
+ return out, nil
+}
+
+func (m *Repo) ListMemberIDs(ctx context.Context, groupID int64) ([]int64, error) {
+ if groupID <= 0 {
+ return nil, errs.ErrInvalidArgument
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var ids []int64
+ err = db.Model(&GroupMember{}).Where("group_id = ?", groupID).Order("joined_at ASC").Pluck("user_id", &ids).Error
+ if err != nil {
+ return nil, mapDBErr(err)
+ }
+ return ids, nil
+}
+
+func (m *Repo) UpdateMember(ctx context.Context, mem *entity.Member) error {
+ if mem == nil || mem.GroupID <= 0 || mem.UserID <= 0 {
+ return errs.ErrInvalidArgument
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ res := db.Model(&GroupMember{}).Where("group_id = ? AND user_id = ?", mem.GroupID, mem.UserID).Updates(map[string]any{
+ "role": mem.Role,
+ "nickname": mem.Nickname,
+ "speak_muted": boolInt(mem.SpeakMuted),
+ })
+ if res.Error != nil {
+ return mapDBErr(res.Error)
+ }
+ if res.RowsAffected == 0 {
+ return errs.ErrNotFound
+ }
+ return nil
+}
diff --git a/internal/module/group/model/memory.go b/internal/module/group/model/memory.go
new file mode 100644
index 0000000..a30d014
--- /dev/null
+++ b/internal/module/group/model/memory.go
@@ -0,0 +1,190 @@
+package model
+
+import (
+ "context"
+ "sort"
+ "sync"
+ "time"
+
+ "spark-server/internal/module/group/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+type lastMsg struct {
+ ID int64
+ At int64
+}
+
+// MemoryRepo is an in-process group store for tests and empty-DSN bring-up.
+type MemoryRepo struct {
+ mu sync.Mutex
+ nextID int64
+ groups map[int64]*entity.Group
+ byConv map[int64]int64
+ members map[int64]map[int64]*entity.Member
+ last map[int64]lastMsg
+}
+
+func NewMemoryRepo() *MemoryRepo {
+ return &MemoryRepo{
+ groups: make(map[int64]*entity.Group),
+ byConv: make(map[int64]int64),
+ members: make(map[int64]map[int64]*entity.Member),
+ last: make(map[int64]lastMsg),
+ }
+}
+
+func (m *MemoryRepo) Get(_ context.Context, id int64) (*entity.Group, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return copyGroup(m.groups[id])
+}
+
+func (m *MemoryRepo) GetByConversation(_ context.Context, conversationID int64) (*entity.Group, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ id, ok := m.byConv[conversationID]
+ if !ok {
+ return nil, errs.ErrNotFound
+ }
+ return copyGroup(m.groups[id])
+}
+
+func (m *MemoryRepo) ListByUser(_ context.Context, userID int64, limit int) ([]*entity.Group, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ limit = entity.ClampLimit(limit)
+ out := make([]*entity.Group, 0)
+ for gid, byUser := range m.members {
+ if _, ok := byUser[userID]; !ok {
+ continue
+ }
+ g, ok := m.groups[gid]
+ if !ok || g.Status != entity.StatusActive {
+ continue
+ }
+ cp := *g
+ out = append(out, &cp)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAtMS > out[j].UpdatedAtMS })
+ if len(out) > limit {
+ out = out[:limit]
+ }
+ return out, nil
+}
+
+func (m *MemoryRepo) GetMember(_ context.Context, groupID, userID int64) (*entity.Member, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ byUser, ok := m.members[groupID]
+ if !ok {
+ return nil, errs.ErrNotFound
+ }
+ mem, ok := byUser[userID]
+ if !ok {
+ return nil, errs.ErrNotFound
+ }
+ cp := *mem
+ return &cp, nil
+}
+
+func (m *MemoryRepo) ListMembers(_ context.Context, groupID int64, limit int) ([]*entity.Member, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ byUser, ok := m.members[groupID]
+ if !ok {
+ return nil, errs.ErrNotFound
+ }
+ limit = entity.ClampMemberLimit(limit)
+ out := make([]*entity.Member, 0, len(byUser))
+ for _, mem := range byUser {
+ cp := *mem
+ out = append(out, &cp)
+ }
+ sort.Slice(out, func(i, j int) bool {
+ if out[i].Role != out[j].Role {
+ return out[i].Role < out[j].Role
+ }
+ return out[i].JoinedAtMS < out[j].JoinedAtMS
+ })
+ if len(out) > limit {
+ out = out[:limit]
+ }
+ return out, nil
+}
+
+func (m *MemoryRepo) ListMemberIDs(_ context.Context, groupID int64) ([]int64, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ byUser, ok := m.members[groupID]
+ if !ok {
+ return nil, errs.ErrNotFound
+ }
+ ids := make([]int64, 0, len(byUser))
+ for uid := range byUser {
+ ids = append(ids, uid)
+ }
+ sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
+ return ids, nil
+}
+
+func (m *MemoryRepo) UpdateProfile(_ context.Context, g *entity.Group) error {
+ if g == nil {
+ return errs.ErrInvalidArgument
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ cur, ok := m.groups[g.ID]
+ if !ok || cur.Status != entity.StatusActive {
+ return errs.ErrNotFound
+ }
+ cur.Name = g.Name
+ cur.Avatar = g.Avatar
+ cur.MutedAll = g.MutedAll
+ cur.UpdatedAtMS = g.UpdatedAtMS
+ if cur.UpdatedAtMS <= 0 {
+ cur.UpdatedAtMS = time.Now().UnixMilli()
+ }
+ return nil
+}
+
+func (m *MemoryRepo) UpdateMember(_ context.Context, mem *entity.Member) error {
+ if mem == nil {
+ return errs.ErrInvalidArgument
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ byUser, ok := m.members[mem.GroupID]
+ if !ok {
+ return errs.ErrNotFound
+ }
+ cur, ok := byUser[mem.UserID]
+ if !ok {
+ return errs.ErrNotFound
+ }
+ cur.Role = mem.Role
+ cur.Nickname = mem.Nickname
+ cur.SpeakMuted = mem.SpeakMuted
+ return nil
+}
+
+func (m *MemoryRepo) UpdateLastMsg(_ context.Context, conversationID, msgID, atMS int64) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if _, ok := m.byConv[conversationID]; !ok {
+ return errs.ErrNotFound
+ }
+ if atMS <= 0 {
+ atMS = time.Now().UnixMilli()
+ }
+ m.last[conversationID] = lastMsg{ID: msgID, At: atMS}
+ return nil
+}
+
+func copyGroup(g *entity.Group) (*entity.Group, error) {
+ if g == nil {
+ return nil, errs.ErrNotFound
+ }
+ cp := *g
+ return &cp, nil
+}
diff --git a/internal/module/group/model/memory_write.go b/internal/module/group/model/memory_write.go
new file mode 100644
index 0000000..680724f
--- /dev/null
+++ b/internal/module/group/model/memory_write.go
@@ -0,0 +1,149 @@
+package model
+
+import (
+ "context"
+ "time"
+
+ "spark-server/internal/module/group/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+func (m *MemoryRepo) CreateGroup(_ context.Context, g *entity.Group, members []*entity.Member) error {
+ if g == nil || g.ConversationID <= 0 || len(members) == 0 {
+ return errs.ErrInvalidArgument
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if _, ok := m.byConv[g.ConversationID]; ok {
+ return errs.ErrDuplicate
+ }
+ m.nextID++
+ cp := *g
+ cp.ID = m.nextID
+ cp.MemberCount = len(members)
+ if cp.Status == 0 {
+ cp.Status = entity.StatusActive
+ }
+ if cp.CreatedAtMS <= 0 {
+ cp.CreatedAtMS = time.Now().UnixMilli()
+ }
+ if cp.UpdatedAtMS <= 0 {
+ cp.UpdatedAtMS = cp.CreatedAtMS
+ }
+ m.groups[cp.ID] = &cp
+ m.byConv[cp.ConversationID] = cp.ID
+ g.ID = cp.ID
+ byUser := make(map[int64]*entity.Member, len(members))
+ for _, mem := range members {
+ if mem == nil || mem.UserID <= 0 {
+ continue
+ }
+ row := *mem
+ row.GroupID = cp.ID
+ if row.JoinedAtMS <= 0 {
+ row.JoinedAtMS = cp.CreatedAtMS
+ }
+ if row.Role == 0 {
+ row.Role = entity.RoleMember
+ }
+ byUser[row.UserID] = &row
+ }
+ m.members[cp.ID] = byUser
+ return nil
+}
+
+func (m *MemoryRepo) AddMembers(_ context.Context, groupID int64, members []*entity.Member) error {
+ if groupID <= 0 || len(members) == 0 {
+ return errs.ErrInvalidArgument
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ g, ok := m.groups[groupID]
+ if !ok || g.Status != entity.StatusActive {
+ return errs.ErrNotFound
+ }
+ byUser := m.members[groupID]
+ if byUser == nil {
+ byUser = make(map[int64]*entity.Member)
+ m.members[groupID] = byUser
+ }
+ now := time.Now().UnixMilli()
+ for _, mem := range members {
+ if mem == nil || mem.UserID <= 0 {
+ continue
+ }
+ if _, ok := byUser[mem.UserID]; ok {
+ return errs.ErrDuplicate
+ }
+ row := *mem
+ row.GroupID = groupID
+ if row.JoinedAtMS <= 0 {
+ row.JoinedAtMS = now
+ }
+ if row.Role == 0 {
+ row.Role = entity.RoleMember
+ }
+ byUser[row.UserID] = &row
+ }
+ g.MemberCount = len(byUser)
+ g.UpdatedAtMS = now
+ return nil
+}
+
+func (m *MemoryRepo) RemoveMember(_ context.Context, groupID, userID int64) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ g, ok := m.groups[groupID]
+ if !ok || g.Status != entity.StatusActive {
+ return errs.ErrNotFound
+ }
+ byUser := m.members[groupID]
+ if byUser == nil {
+ return errs.ErrNotFound
+ }
+ if _, ok := byUser[userID]; !ok {
+ return errs.ErrNotFound
+ }
+ delete(byUser, userID)
+ g.MemberCount = len(byUser)
+ g.UpdatedAtMS = time.Now().UnixMilli()
+ return nil
+}
+
+func (m *MemoryRepo) Dissolve(_ context.Context, groupID int64) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ g, ok := m.groups[groupID]
+ if !ok || g.Status != entity.StatusActive {
+ return errs.ErrNotFound
+ }
+ g.Status = entity.StatusDissolved
+ g.MemberCount = 0
+ g.UpdatedAtMS = time.Now().UnixMilli()
+ delete(m.members, groupID)
+ return nil
+}
+
+func (m *MemoryRepo) TransferOwner(_ context.Context, groupID, fromID, toID int64) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ g, ok := m.groups[groupID]
+ if !ok || g.Status != entity.StatusActive || g.OwnerID != fromID {
+ return errs.ErrNotFound
+ }
+ byUser := m.members[groupID]
+ from, ok := byUser[fromID]
+ if !ok {
+ return errs.ErrNotFound
+ }
+ to, ok := byUser[toID]
+ if !ok {
+ return errs.ErrNotFound
+ }
+ from.Role = entity.RoleMember
+ to.Role = entity.RoleOwner
+ to.SpeakMuted = false
+ g.OwnerID = toID
+ g.UpdatedAtMS = time.Now().UnixMilli()
+ return nil
+}
diff --git a/internal/module/group/model/repo.go b/internal/module/group/model/repo.go
new file mode 100644
index 0000000..8eae8c4
--- /dev/null
+++ b/internal/module/group/model/repo.go
@@ -0,0 +1,41 @@
+package model
+
+import (
+ "context"
+ "errors"
+
+ mysqldriver "github.com/go-sql-driver/mysql"
+ "gorm.io/gorm"
+
+ "spark-server/internal/pkg/errs"
+)
+
+// Repo is MySQL persistence for groups / group_members (and conversation rows in the same txn).
+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("group-mysql", err)
+}
diff --git a/internal/module/group/model/write.go b/internal/module/group/model/write.go
new file mode 100644
index 0000000..be8fbf3
--- /dev/null
+++ b/internal/module/group/model/write.go
@@ -0,0 +1,210 @@
+package model
+
+import (
+ "context"
+ "time"
+
+ "gorm.io/gorm"
+
+ conventity "spark-server/internal/module/conversation/entity"
+ convmodel "spark-server/internal/module/conversation/model"
+ "spark-server/internal/module/group/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+func (m *Repo) CreateGroup(ctx context.Context, g *entity.Group, members []*entity.Member) error {
+ if g == nil || g.ConversationID <= 0 || len(members) == 0 {
+ return errs.ErrInvalidArgument
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ now := msTime(g.CreatedAtMS)
+ return db.Transaction(func(tx *gorm.DB) error {
+ conv := convmodel.Conversation{
+ ID: g.ConversationID, Type: conventity.TypeGroup, LastMsgAt: now, CreatedAt: now,
+ }
+ if err := tx.Create(&conv).Error; err != nil {
+ return mapDBErr(err)
+ }
+ g.ID = 0
+ g.MemberCount = len(members)
+ row := groupRow(g)
+ if err := tx.Create(row).Error; err != nil {
+ return mapDBErr(err)
+ }
+ g.ID = row.ID
+ for _, mem := range members {
+ if mem == nil {
+ continue
+ }
+ mem.GroupID = g.ID
+ if err := insertMember(tx, mem, g.ConversationID, now); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+}
+
+func (m *Repo) AddMembers(ctx context.Context, groupID int64, members []*entity.Member) error {
+ if groupID <= 0 || len(members) == 0 {
+ return errs.ErrInvalidArgument
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ now := time.Now()
+ return db.Transaction(func(tx *gorm.DB) error {
+ g, err := takeActiveGroup(tx, groupID)
+ if err != nil {
+ return err
+ }
+ for _, mem := range members {
+ if mem == nil {
+ continue
+ }
+ mem.GroupID = groupID
+ if err := insertMember(tx, mem, g.ConversationID, now); err != nil {
+ return err
+ }
+ }
+ return bumpMemberCount(tx, groupID, len(members), now)
+ })
+}
+
+func (m *Repo) RemoveMember(ctx context.Context, groupID, userID int64) error {
+ if groupID <= 0 || userID <= 0 {
+ return errs.ErrInvalidArgument
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ now := time.Now()
+ return db.Transaction(func(tx *gorm.DB) error {
+ g, err := takeActiveGroup(tx, groupID)
+ if err != nil {
+ return err
+ }
+ res := tx.Where("group_id = ? AND user_id = ?", groupID, userID).Delete(&GroupMember{})
+ if res.Error != nil {
+ return mapDBErr(res.Error)
+ }
+ if res.RowsAffected == 0 {
+ return errs.ErrNotFound
+ }
+ if err := tx.Where("conversation_id = ? AND user_id = ?", g.ConversationID, userID).
+ Delete(&convmodel.ConversationMember{}).Error; err != nil {
+ return mapDBErr(err)
+ }
+ return bumpMemberCount(tx, groupID, -1, now)
+ })
+}
+
+func (m *Repo) Dissolve(ctx context.Context, groupID int64) error {
+ if groupID <= 0 {
+ return errs.ErrInvalidArgument
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ now := time.Now()
+ return db.Transaction(func(tx *gorm.DB) error {
+ g, err := takeActiveGroup(tx, groupID)
+ if err != nil {
+ return err
+ }
+ res := tx.Model(&Group{}).Where("id = ? AND status = ?", groupID, entity.StatusActive).Updates(map[string]any{
+ "status": entity.StatusDissolved,
+ "member_count": 0,
+ "updated_at": now,
+ })
+ if res.Error != nil {
+ return mapDBErr(res.Error)
+ }
+ if res.RowsAffected == 0 {
+ return errs.ErrNotFound
+ }
+ if err := tx.Where("group_id = ?", groupID).Delete(&GroupMember{}).Error; err != nil {
+ return mapDBErr(err)
+ }
+ if err := tx.Where("conversation_id = ?", g.ConversationID).Delete(&convmodel.ConversationMember{}).Error; err != nil {
+ return mapDBErr(err)
+ }
+ return nil
+ })
+}
+
+func (m *Repo) TransferOwner(ctx context.Context, groupID, fromID, toID int64) error {
+ if groupID <= 0 || fromID <= 0 || toID <= 0 || fromID == toID {
+ return errs.ErrInvalidArgument
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ now := time.Now()
+ return db.Transaction(func(tx *gorm.DB) error {
+ res := tx.Model(&Group{}).Where("id = ? AND owner_id = ? AND status = ?", groupID, fromID, entity.StatusActive).
+ Updates(map[string]any{"owner_id": toID, "updated_at": now})
+ if res.Error != nil {
+ return mapDBErr(res.Error)
+ }
+ if res.RowsAffected == 0 {
+ return errs.ErrNotFound
+ }
+ if err := tx.Model(&GroupMember{}).Where("group_id = ? AND user_id = ?", groupID, fromID).
+ Update("role", entity.RoleMember).Error; err != nil {
+ return mapDBErr(err)
+ }
+ res = tx.Model(&GroupMember{}).Where("group_id = ? AND user_id = ?", groupID, toID).
+ Updates(map[string]any{"role": entity.RoleOwner, "speak_muted": 0})
+ if res.Error != nil {
+ return mapDBErr(res.Error)
+ }
+ if res.RowsAffected == 0 {
+ return errs.ErrNotFound
+ }
+ return nil
+ })
+}
+
+func takeActiveGroup(tx *gorm.DB, groupID int64) (*Group, error) {
+ var row Group
+ if err := tx.Where("id = ? AND status = ?", groupID, entity.StatusActive).Take(&row).Error; err != nil {
+ return nil, mapDBErr(err)
+ }
+ return &row, nil
+}
+
+func insertMember(tx *gorm.DB, mem *entity.Member, conversationID int64, now time.Time) error {
+ if mem.JoinedAtMS <= 0 {
+ mem.JoinedAtMS = now.UnixMilli()
+ }
+ if err := tx.Create(memberRow(mem)).Error; err != nil {
+ return mapDBErr(err)
+ }
+ row := convmodel.ConversationMember{
+ ConversationID: conversationID, UserID: mem.UserID, JoinedAt: msTime(mem.JoinedAtMS),
+ }
+ return mapDBErr(tx.Create(&row).Error)
+}
+
+func bumpMemberCount(tx *gorm.DB, groupID int64, delta int, now time.Time) error {
+ res := tx.Model(&Group{}).Where("id = ? AND status = ?", groupID, entity.StatusActive).
+ Updates(map[string]any{
+ "member_count": gorm.Expr("member_count + ?", delta),
+ "updated_at": now,
+ })
+ if res.Error != nil {
+ return mapDBErr(res.Error)
+ }
+ if res.RowsAffected == 0 {
+ return errs.ErrNotFound
+ }
+ return nil
+}
diff --git a/internal/module/group/service.go b/internal/module/group/service.go
deleted file mode 100644
index cbe4309..0000000
--- a/internal/module/group/service.go
+++ /dev/null
@@ -1,8 +0,0 @@
-package group
-
-import "context"
-
-type Service interface {
- CanSend(ctx context.Context, userID, groupID int64) error
- MemberIDs(ctx context.Context, groupID int64) ([]int64, error)
-}
diff --git a/internal/module/inbox/entity/preview.go b/internal/module/inbox/entity/preview.go
new file mode 100644
index 0000000..b4275fa
--- /dev/null
+++ b/internal/module/inbox/entity/preview.go
@@ -0,0 +1,8 @@
+// Package entity holds inbox preview types. List/unread live in Redis, not MySQL.
+package entity
+
+type Preview struct {
+ ConversationID int64
+ MsgID int64
+ Snippet []byte
+}
diff --git a/internal/module/inbox/store.go b/internal/module/inbox/logic/store.go
similarity index 80%
rename from internal/module/inbox/store.go
rename to internal/module/inbox/logic/store.go
index ebcaafe..3438ff0 100644
--- a/internal/module/inbox/store.go
+++ b/internal/module/inbox/logic/store.go
@@ -1,8 +1,8 @@
-package inbox
+// Package logic is the Redis inbox (conversation list) contract. No MySQL on this hot path.
+package logic
import "context"
-// Store is the Redis ZSET conversation list. MySQL is not on this hot path.
type Store interface {
TouchUnread(ctx context.Context, userID, conversationID int64, score int64) error
TouchRead(ctx context.Context, userID, conversationID int64, score int64) error
diff --git a/internal/module/message/entity/content.go b/internal/module/message/entity/content.go
new file mode 100644
index 0000000..66ad0d5
--- /dev/null
+++ b/internal/module/message/entity/content.go
@@ -0,0 +1,298 @@
+package entity
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "unicode/utf8"
+)
+
+// Content is the in-memory message body. Persist only Marshal() bytes into messages.content.
+type Content struct {
+ Type Type `json:"t"`
+ Text *Text `json:"text,omitempty"`
+ Image *Image `json:"image,omitempty"`
+ Voice *Voice `json:"voice,omitempty"`
+ Video *Video `json:"video,omitempty"`
+ Emoji *Emoji `json:"emoji,omitempty"`
+ GIF *GIF `json:"gif,omitempty"`
+ File *File `json:"file,omitempty"`
+ Location *Location `json:"location,omitempty"`
+ System *System `json:"system,omitempty"`
+}
+
+func NewText(body string) *Content {
+ return &Content{Type: TypeText, Text: &Text{Body: body}}
+}
+
+func NewImage(img *Image) *Content {
+ return &Content{Type: TypeImage, Image: img}
+}
+
+func NewVoice(v *Voice) *Content {
+ return &Content{Type: TypeVoice, Voice: v}
+}
+
+func NewVideo(v *Video) *Content {
+ return &Content{Type: TypeVideo, Video: v}
+}
+
+func NewEmoji(e *Emoji) *Content {
+ return &Content{Type: TypeEmoji, Emoji: e}
+}
+
+func NewGIF(g *GIF) *Content {
+ return &Content{Type: TypeGIF, GIF: g}
+}
+
+func NewFile(f *File) *Content {
+ return &Content{Type: TypeFile, File: f}
+}
+
+func NewLocation(loc *Location) *Content {
+ return &Content{Type: TypeLocation, Location: loc}
+}
+
+func NewSystem(s *System) *Content {
+ return &Content{Type: TypeSystem, System: s}
+}
+
+// Marshal serializes content to JSON bytes for the BLOB column. It never returns a Go string.
+func (c *Content) Marshal() ([]byte, error) {
+ if err := c.Validate(); err != nil {
+ return nil, err
+ }
+ var buf bytes.Buffer
+ enc := json.NewEncoder(&buf)
+ enc.SetEscapeHTML(false)
+ if err := enc.Encode(c); err != nil {
+ return nil, fmt.Errorf("content: %w", err)
+ }
+ b := bytes.TrimSuffix(buf.Bytes(), []byte{'\n'})
+ if len(b) > MaxContentBytes {
+ return nil, fmt.Errorf("content: exceeds %d bytes", MaxContentBytes)
+ }
+ return b, nil
+}
+
+// UnmarshalContent decodes stored BLOB bytes. Rejects a plain UTF-8 string that is not JSON.
+func UnmarshalContent(raw []byte) (*Content, error) {
+ if len(raw) == 0 {
+ return nil, fmt.Errorf("content: empty")
+ }
+ if len(raw) > MaxContentBytes {
+ return nil, fmt.Errorf("content: exceeds %d bytes", MaxContentBytes)
+ }
+ var c Content
+ if err := json.Unmarshal(raw, &c); err != nil {
+ return nil, fmt.Errorf("content: %w", err)
+ }
+ if err := c.Validate(); err != nil {
+ return nil, err
+ }
+ return &c, nil
+}
+
+func (c *Content) Validate() error {
+ if c == nil {
+ return fmt.Errorf("content: empty")
+ }
+ if !c.Type.Valid() {
+ return fmt.Errorf("content: unknown type %d", c.Type)
+ }
+ got, n := c.populated()
+ if n != 1 {
+ return fmt.Errorf("content: type %d wants exactly one payload, got %d", c.Type, n)
+ }
+ if got != c.Type {
+ return fmt.Errorf("content: type %d does not match payload %d", c.Type, got)
+ }
+ switch c.Type {
+ case TypeText:
+ return validateText(c.Text)
+ case TypeImage:
+ return validateMediaKey("image", c.Image.Key)
+ case TypeVoice:
+ return validateVoice(c.Voice)
+ case TypeVideo:
+ return validateMediaKey("video", c.Video.Key)
+ case TypeEmoji:
+ return validateEmoji(c.Emoji)
+ case TypeGIF:
+ return validateMediaKey("gif", c.GIF.Key)
+ case TypeFile:
+ return validateFile(c.File)
+ case TypeLocation:
+ return validateLocation(c.Location)
+ case TypeSystem:
+ return validateSystem(c.System)
+ default:
+ return fmt.Errorf("content: unknown type %d", c.Type)
+ }
+}
+
+func (c *Content) populated() (Type, int) {
+ n := 0
+ var t Type
+ if c.Text != nil {
+ n++
+ t = TypeText
+ }
+ if c.Image != nil {
+ n++
+ t = TypeImage
+ }
+ if c.Voice != nil {
+ n++
+ t = TypeVoice
+ }
+ if c.Video != nil {
+ n++
+ t = TypeVideo
+ }
+ if c.Emoji != nil {
+ n++
+ t = TypeEmoji
+ }
+ if c.GIF != nil {
+ n++
+ t = TypeGIF
+ }
+ if c.File != nil {
+ n++
+ t = TypeFile
+ }
+ if c.Location != nil {
+ n++
+ t = TypeLocation
+ }
+ if c.System != nil {
+ n++
+ t = TypeSystem
+ }
+ return t, n
+}
+
+func validateText(t *Text) error {
+ body := strings.TrimSpace(t.Body)
+ if body == "" {
+ return fmt.Errorf("content: text empty")
+ }
+ if !utf8.ValidString(t.Body) {
+ return fmt.Errorf("content: text not utf-8")
+ }
+ return nil
+}
+
+func validateMediaKey(kind, key string) error {
+ if strings.TrimSpace(key) == "" {
+ return fmt.Errorf("content: %s key empty", kind)
+ }
+ return nil
+}
+
+func validateVoice(v *Voice) error {
+ if err := validateMediaKey("voice", v.Key); err != nil {
+ return err
+ }
+ if v.DurationMS < 0 {
+ return fmt.Errorf("content: voice duration")
+ }
+ return nil
+}
+
+func validateEmoji(e *Emoji) error {
+ if strings.TrimSpace(e.Pack) == "" || strings.TrimSpace(e.ID) == "" {
+ return fmt.Errorf("content: emoji pack/id empty")
+ }
+ return nil
+}
+
+func validateFile(f *File) error {
+ if err := validateMediaKey("file", f.Key); err != nil {
+ return err
+ }
+ if strings.TrimSpace(f.Name) == "" {
+ return fmt.Errorf("content: file name empty")
+ }
+ if f.Size < 0 {
+ return fmt.Errorf("content: file size")
+ }
+ return nil
+}
+
+func validateLocation(l *Location) error {
+ if l.Lat < -90 || l.Lat > 90 || l.Lng < -180 || l.Lng > 180 {
+ return fmt.Errorf("content: location range")
+ }
+ return nil
+}
+
+func validateSystem(s *System) error {
+ if strings.TrimSpace(s.Kind) == "" {
+ return fmt.Errorf("content: system kind empty")
+ }
+ return nil
+}
+
+// ObjectKeys returns media object keys in this payload (image/voice/video/gif/file).
+func (c *Content) ObjectKeys() []string {
+ if c == nil {
+ return nil
+ }
+ var keys []string
+ add := func(k string) {
+ k = strings.TrimSpace(k)
+ if k != "" {
+ keys = append(keys, k)
+ }
+ }
+ switch {
+ case c.Image != nil:
+ add(c.Image.Key)
+ add(c.Image.ThumbKey)
+ case c.Voice != nil:
+ add(c.Voice.Key)
+ case c.Video != nil:
+ add(c.Video.Key)
+ add(c.Video.CoverKey)
+ case c.GIF != nil:
+ add(c.GIF.Key)
+ case c.File != nil:
+ add(c.File.Key)
+ }
+ return keys
+}
+
+// ReplaceObjectKeys rewrites keys after CopyObject into perm/fav/.
+func (c *Content) ReplaceObjectKeys(mapping map[string]string) {
+ if c == nil || len(mapping) == 0 {
+ return
+ }
+ repl := func(s *string) {
+ if s == nil {
+ return
+ }
+ if n, ok := mapping[*s]; ok {
+ *s = n
+ }
+ }
+ if c.Image != nil {
+ repl(&c.Image.Key)
+ repl(&c.Image.ThumbKey)
+ }
+ if c.Voice != nil {
+ repl(&c.Voice.Key)
+ }
+ if c.Video != nil {
+ repl(&c.Video.Key)
+ repl(&c.Video.CoverKey)
+ }
+ if c.GIF != nil {
+ repl(&c.GIF.Key)
+ }
+ if c.File != nil {
+ repl(&c.File.Key)
+ }
+}
diff --git a/internal/module/message/entity/content_test.go b/internal/module/message/entity/content_test.go
new file mode 100644
index 0000000..b2909b7
--- /dev/null
+++ b/internal/module/message/entity/content_test.go
@@ -0,0 +1,99 @@
+package entity
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+func TestTextMarshalIsJSONBytesNotRawString(t *testing.T) {
+ c := NewText("hello")
+ raw, err := c.Marshal()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bytes.Equal(raw, []byte("hello")) || !json.Valid(raw) {
+ t.Fatalf("want json bytes, got %q", raw)
+ }
+ if !bytes.Contains(raw, []byte(`"t":1`)) || !bytes.Contains(raw, []byte(`"body":"hello"`)) {
+ t.Fatalf("%s", raw)
+ }
+ got, err := UnmarshalContent(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Type != TypeText || got.Text == nil || got.Text.Body != "hello" {
+ t.Fatalf("%+v", got)
+ }
+}
+
+func TestMediaTypesRequireObjectKey(t *testing.T) {
+ cases := []*Content{
+ NewImage(&Image{Key: "obj/a.jpg", Width: 10, Height: 10, Mime: "image/jpeg"}),
+ NewVoice(&Voice{Key: "obj/a.m4a", DurationMS: 1200}),
+ NewVideo(&Video{Key: "obj/a.mp4", CoverKey: "obj/c.jpg"}),
+ NewGIF(&GIF{Key: "obj/a.gif", Mime: "image/gif"}),
+ NewFile(&File{Key: "obj/a.pdf", Name: "a.pdf", Size: 9}),
+ NewEmoji(&Emoji{Pack: "spark", ID: "smile"}),
+ NewLocation(&Location{Lat: 31.2, Lng: 121.5, Name: "Shanghai"}),
+ NewSystem(&System{Kind: "member_join", Body: "joined"}),
+ }
+ for _, c := range cases {
+ raw, err := c.Marshal()
+ if err != nil {
+ t.Fatalf("type %d: %v", c.Type, err)
+ }
+ if !json.Valid(raw) {
+ t.Fatalf("type %d not json %q", c.Type, raw)
+ }
+ got, err := UnmarshalContent(raw)
+ if err != nil || got.Type != c.Type {
+ t.Fatalf("type %d roundtrip %v %+v", c.Type, err, got)
+ }
+ }
+}
+
+func TestRejectsRawStringAndEmptyKey(t *testing.T) {
+ if _, err := UnmarshalContent([]byte("just a string")); err == nil {
+ t.Fatal("raw string")
+ }
+ if _, err := NewText(" ").Marshal(); err == nil {
+ t.Fatal("blank text")
+ }
+ if _, err := NewImage(&Image{}).Marshal(); err == nil || !strings.Contains(err.Error(), "key") {
+ t.Fatalf("image key: %v", err)
+ }
+ bad := &Content{Type: TypeText, Text: &Text{Body: "x"}, Image: &Image{Key: "k"}}
+ if _, err := bad.Marshal(); err == nil {
+ t.Fatal("two payloads")
+ }
+}
+
+func TestMessagePayloadChecksColumnType(t *testing.T) {
+ raw, err := NewText("hi").Marshal()
+ if err != nil {
+ t.Fatal(err)
+ }
+ m := &Message{Type: TypeImage, Content: raw}
+ if _, err := m.Payload(); err == nil {
+ t.Fatal("mismatch")
+ }
+ m.Type = TypeText
+ c, err := m.Payload()
+ if err != nil || c.Text.Body != "hi" {
+ t.Fatalf("%v %+v", err, c)
+ }
+}
+
+func TestObjectKeysAndReplace(t *testing.T) {
+ img := NewImage(&Image{Key: "chat/a.jpg", ThumbKey: "chat/t.jpg"})
+ keys := img.ObjectKeys()
+ if len(keys) != 2 || keys[0] != "chat/a.jpg" {
+ t.Fatalf("%v", keys)
+ }
+ img.ReplaceObjectKeys(map[string]string{"chat/a.jpg": "perm/fav/1/x.jpg", "chat/t.jpg": "perm/fav/1/y.jpg"})
+ if img.Image.Key != "perm/fav/1/x.jpg" || img.Image.ThumbKey != "perm/fav/1/y.jpg" {
+ t.Fatalf("%+v", img.Image)
+ }
+}
diff --git a/internal/module/message/entity/message.go b/internal/module/message/entity/message.go
new file mode 100644
index 0000000..bcd52d3
--- /dev/null
+++ b/internal/module/message/entity/message.go
@@ -0,0 +1,29 @@
+package entity
+
+import "fmt"
+
+// Message is a persisted IM body. Content is serialized JSON bytes, not a string.
+type Message struct {
+ ID int64
+ ConversationID int64
+ SenderID int64
+ ClientMsgID string
+ Type Type
+ Content []byte
+ Status int
+ CreatedAtMS int64
+}
+
+func (m *Message) Payload() (*Content, error) {
+ if m == nil {
+ return nil, nil
+ }
+ c, err := UnmarshalContent(m.Content)
+ if err != nil {
+ return nil, err
+ }
+ if c.Type != m.Type {
+ return nil, fmt.Errorf("content: column type %d != payload %d", m.Type, c.Type)
+ }
+ return c, nil
+}
diff --git a/internal/module/message/entity/payload.go b/internal/module/message/entity/payload.go
new file mode 100644
index 0000000..d987e27
--- /dev/null
+++ b/internal/module/message/entity/payload.go
@@ -0,0 +1,71 @@
+package entity
+
+// Text is a plain-text body. Stored as JSON bytes, never as a raw SQL string column.
+type Text struct {
+ Body string `json:"body"`
+}
+
+// Image references an uploaded object. Bytes themselves are not stored in messages.content.
+type Image struct {
+ Key string `json:"key"`
+ Mime string `json:"mime,omitempty"`
+ Width int `json:"w,omitempty"`
+ Height int `json:"h,omitempty"`
+ Size int64 `json:"size,omitempty"`
+ ThumbKey string `json:"thumb_key,omitempty"`
+}
+
+// Voice is an audio clip in object storage.
+type Voice struct {
+ Key string `json:"key"`
+ DurationMS int `json:"duration_ms"`
+ Size int64 `json:"size,omitempty"`
+}
+
+// Video is a video clip in object storage.
+type Video struct {
+ Key string `json:"key"`
+ Mime string `json:"mime,omitempty"`
+ Width int `json:"w,omitempty"`
+ Height int `json:"h,omitempty"`
+ DurationMS int `json:"duration_ms,omitempty"`
+ Size int64 `json:"size,omitempty"`
+ CoverKey string `json:"cover_key,omitempty"`
+}
+
+// Emoji is a sticker from a pack (not an image file in content).
+type Emoji struct {
+ Pack string `json:"pack"`
+ ID string `json:"id"`
+}
+
+// GIF is an animated image in object storage.
+type GIF struct {
+ Key string `json:"key"`
+ Mime string `json:"mime,omitempty"`
+ Width int `json:"w,omitempty"`
+ Height int `json:"h,omitempty"`
+ Size int64 `json:"size,omitempty"`
+}
+
+// File is a generic attachment in object storage.
+type File struct {
+ Key string `json:"key"`
+ Name string `json:"name"`
+ Size int64 `json:"size"`
+ Mime string `json:"mime,omitempty"`
+}
+
+// Location is a geographic pin.
+type Location struct {
+ Lat float64 `json:"lat"`
+ Lng float64 `json:"lng"`
+ Name string `json:"name,omitempty"`
+ Address string `json:"address,omitempty"`
+}
+
+// System is a server-generated notice (member join, recall hint, etc.).
+type System struct {
+ Kind string `json:"kind"`
+ Body string `json:"body,omitempty"`
+}
diff --git a/internal/module/message/entity/type.go b/internal/module/message/entity/type.go
new file mode 100644
index 0000000..51323f6
--- /dev/null
+++ b/internal/module/message/entity/type.go
@@ -0,0 +1,30 @@
+// Package entity holds message domain types. Content is serialized to bytes for MySQL.
+package entity
+
+// Type is persisted as messages.message_type. Values are stable; do not reuse.
+type Type int16
+
+const (
+ TypeText Type = 1
+ TypeImage Type = 2
+ TypeVoice Type = 3
+ TypeVideo Type = 4
+ TypeEmoji Type = 5
+ TypeGIF Type = 6
+ TypeFile Type = 7
+ TypeLocation Type = 8
+ TypeSystem Type = 9
+)
+
+func (t Type) Valid() bool {
+ return t >= TypeText && t <= TypeSystem
+}
+
+const (
+ StatusNormal = 1
+ StatusRecalled = 2
+ StatusHidden = 3
+)
+
+// MaxContentBytes is the serialized content size cap (matches TCP Frame max).
+const MaxContentBytes = 65536
diff --git a/internal/module/message/logic/content.go b/internal/module/message/logic/content.go
new file mode 100644
index 0000000..253ba46
--- /dev/null
+++ b/internal/module/message/logic/content.go
@@ -0,0 +1,19 @@
+package logic
+
+import (
+ "fmt"
+
+ "spark-server/internal/module/message/entity"
+)
+
+// EncodeContent serializes a typed payload to BLOB bytes. Callers must store the bytes, not a string.
+func EncodeContent(c *entity.Content) (entity.Type, []byte, error) {
+ if c == nil {
+ return 0, nil, fmt.Errorf("content: required")
+ }
+ raw, err := c.Marshal()
+ if err != nil {
+ return 0, nil, err
+ }
+ return c.Type, raw, nil
+}
diff --git a/internal/module/message/service.go b/internal/module/message/logic/send.go
similarity index 56%
rename from internal/module/message/service.go
rename to internal/module/message/logic/send.go
index 1595cfe..e2ea751 100644
--- a/internal/module/message/service.go
+++ b/internal/module/message/logic/send.go
@@ -1,23 +1,17 @@
-package message
+// Package logic is the message send and history use-case layer.
+package logic
-import "context"
+import (
+ "context"
-type Message struct {
- ID int64
- ConversationID int64
- SenderID int64
- ClientMsgID string
- Type int
- Content []byte
- Status int
- CreatedAtMS int64
-}
+ "spark-server/internal/module/message/entity"
+)
type Repository interface {
- Create(ctx context.Context, message *Message) error
- GetByID(ctx context.Context, id int64) (*Message, error)
- GetByClientMsgID(ctx context.Context, senderID, conversationID int64, clientMsgID string) (*Message, error)
- ListByConversation(ctx context.Context, conversationID, lastMsgID int64, limit int) ([]*Message, error)
+ Create(ctx context.Context, message *entity.Message) error
+ GetByID(ctx context.Context, id int64) (*entity.Message, error)
+ GetByClientMsgID(ctx context.Context, senderID, conversationID int64, clientMsgID string) (*entity.Message, error)
+ ListByConversation(ctx context.Context, conversationID, lastMsgID int64, limit int) ([]*entity.Message, error)
}
type SendRequest struct {
@@ -26,8 +20,7 @@ type SendRequest struct {
DeviceID uint64
ClientMsgID string
MsgID int64
- Type int
- Content []byte
+ Content *entity.Content
}
type SendResponse struct {
diff --git a/internal/module/message/model/memory.go b/internal/module/message/model/memory.go
new file mode 100644
index 0000000..c831024
--- /dev/null
+++ b/internal/module/message/model/memory.go
@@ -0,0 +1,64 @@
+package model
+
+import (
+ "context"
+ "sync"
+
+ "spark-server/internal/module/message/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+// MemoryRepo is an in-process message store for tests and empty-DSN bring-up.
+type MemoryRepo struct {
+ mu sync.Mutex
+ byID map[int64]*entity.Message
+}
+
+func NewMemoryRepo() *MemoryRepo {
+ return &MemoryRepo{byID: make(map[int64]*entity.Message)}
+}
+
+func (m *MemoryRepo) Create(_ context.Context, message *entity.Message) error {
+ if message == nil || message.ID <= 0 {
+ return errs.ErrInvalidArgument
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if _, ok := m.byID[message.ID]; ok {
+ return errs.ErrDuplicate
+ }
+ cp := *message
+ if cp.Content != nil {
+ cp.Content = append([]byte(nil), message.Content...)
+ }
+ m.byID[message.ID] = &cp
+ return nil
+}
+
+func (m *MemoryRepo) GetByID(_ context.Context, id int64) (*entity.Message, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ row, ok := m.byID[id]
+ if !ok {
+ return nil, errs.ErrNotFound
+ }
+ cp := *row
+ if cp.Content != nil {
+ cp.Content = append([]byte(nil), row.Content...)
+ }
+ return &cp, nil
+}
+
+func (m *MemoryRepo) All() []*entity.Message {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ out := make([]*entity.Message, 0, len(m.byID))
+ for _, row := range m.byID {
+ cp := *row
+ if cp.Content != nil {
+ cp.Content = append([]byte(nil), row.Content...)
+ }
+ out = append(out, &cp)
+ }
+ return out
+}
diff --git a/internal/module/message/model/message.go b/internal/module/message/model/message.go
new file mode 100644
index 0000000..197d137
--- /dev/null
+++ b/internal/module/message/model/message.go
@@ -0,0 +1,53 @@
+// Package model is message persistence. One file per table.
+package model
+
+import (
+ "time"
+
+ "spark-server/internal/module/message/entity"
+)
+
+// Message maps table messages. content is a serialized BLOB, not a string.
+type Message struct {
+ ID int64 `gorm:"column:id;primaryKey;comment:Snowflake MsgId,主键、排序键、同步游标"`
+ ConversationID int64 `gorm:"column:conversation_id;comment:所属会话 id"`
+ SenderID int64 `gorm:"column:sender_id;comment:发送者 user_id"`
+ ClientMsgID string `gorm:"column:client_msg_id;comment:客户端消息 id,与 sender+会话 组成幂等键"`
+ MessageType int16 `gorm:"column:message_type;comment:消息类型:1文本 2图片 3语音 4视频 5表情 6GIF 7文件 8位置 9系统"`
+ Content []byte `gorm:"column:content;type:blob;comment:序列化后的消息体(JSON 字节),禁止存原始 string;媒体只存对象存储 key"`
+ Status int `gorm:"column:status;comment:1=正常 2=已撤回 3=审核隐藏"`
+ CreatedAt time.Time `gorm:"column:created_at;comment:服务端入库时间"`
+}
+
+func (Message) TableName() string { return "messages" }
+
+func (r *Message) Entity() *entity.Message {
+ if r == nil {
+ return nil
+ }
+ return &entity.Message{
+ ID: r.ID,
+ ConversationID: r.ConversationID,
+ SenderID: r.SenderID,
+ ClientMsgID: r.ClientMsgID,
+ Type: entity.Type(r.MessageType),
+ Content: r.Content,
+ Status: r.Status,
+ CreatedAtMS: r.CreatedAt.UnixMilli(),
+ }
+}
+
+func FromEntity(m *entity.Message) *Message {
+ if m == nil {
+ return nil
+ }
+ return &Message{
+ ID: m.ID,
+ ConversationID: m.ConversationID,
+ SenderID: m.SenderID,
+ ClientMsgID: m.ClientMsgID,
+ MessageType: int16(m.Type),
+ Content: m.Content,
+ Status: m.Status,
+ }
+}
diff --git a/internal/module/message/model/repo.go b/internal/module/message/model/repo.go
new file mode 100644
index 0000000..719d50a
--- /dev/null
+++ b/internal/module/message/model/repo.go
@@ -0,0 +1,75 @@
+package model
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ mysqldriver "github.com/go-sql-driver/mysql"
+ "gorm.io/gorm"
+
+ "spark-server/internal/module/message/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+// Repo is MySQL persistence for messages.
+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("message-mysql", err)
+}
+
+func (m *Repo) GetByID(ctx context.Context, id int64) (*entity.Message, error) {
+ if id <= 0 {
+ return nil, errs.ErrInvalidArgument
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var row Message
+ if err := db.Where("id = ?", id).Take(&row).Error; err != nil {
+ return nil, mapDBErr(err)
+ }
+ return row.Entity(), nil
+}
+
+func (m *Repo) Create(ctx context.Context, message *entity.Message) error {
+ if message == nil || message.ID <= 0 {
+ return errs.ErrInvalidArgument
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ row := FromEntity(message)
+ if message.CreatedAtMS > 0 {
+ row.CreatedAt = time.UnixMilli(message.CreatedAtMS)
+ } else {
+ row.CreatedAt = time.Now()
+ }
+ return mapDBErr(db.Create(row).Error)
+}
diff --git a/internal/module/session/policy.go b/internal/module/session/logic/policy.go
similarity index 59%
rename from internal/module/session/policy.go
rename to internal/module/session/logic/policy.go
index bcb573b..1fdca63 100644
--- a/internal/module/session/policy.go
+++ b/internal/module/session/logic/policy.go
@@ -1,4 +1,5 @@
-package sessionmod
+// Package logic owns session kick policy. gnet does not decide which connection to drop.
+package logic
// Policy is owned by the session module, not gnet.
// Same device_id kicks the old connection. Other devices of the user stay.
diff --git a/internal/module/sync/service.go b/internal/module/sync/entity/request.go
similarity index 53%
rename from internal/module/sync/service.go
rename to internal/module/sync/entity/request.go
index fda3b00..5c7221c 100644
--- a/internal/module/sync/service.go
+++ b/internal/module/sync/entity/request.go
@@ -1,6 +1,5 @@
-package syncx
-
-import "context"
+// Package entity holds sync request types.
+package entity
type InboxRequest struct {
UserID int64
@@ -14,8 +13,3 @@ type MessagesRequest struct {
LastMsgID int64
Limit int
}
-
-type Service interface {
- SyncInbox(ctx context.Context, req *InboxRequest) error
- SyncMessages(ctx context.Context, req *MessagesRequest) error
-}
diff --git a/internal/module/sync/logic/service.go b/internal/module/sync/logic/service.go
new file mode 100644
index 0000000..d29f2f2
--- /dev/null
+++ b/internal/module/sync/logic/service.go
@@ -0,0 +1,13 @@
+// Package logic is inbox/message sync use-cases.
+package logic
+
+import (
+ "context"
+
+ "spark-server/internal/module/sync/entity"
+)
+
+type Service interface {
+ SyncInbox(ctx context.Context, req *entity.InboxRequest) error
+ SyncMessages(ctx context.Context, req *entity.MessagesRequest) error
+}
diff --git a/internal/module/sync/model/device_sync_cursor.go b/internal/module/sync/model/device_sync_cursor.go
new file mode 100644
index 0000000..c07da11
--- /dev/null
+++ b/internal/module/sync/model/device_sync_cursor.go
@@ -0,0 +1,14 @@
+package model
+
+import "time"
+
+// DeviceSyncCursor maps table device_sync_cursors.
+type DeviceSyncCursor struct {
+ UserID int64 `gorm:"column:user_id;primaryKey;comment:用户 id"`
+ DeviceID int64 `gorm:"column:device_id;primaryKey;comment:设备 id"`
+ MailboxSeq int64 `gorm:"column:mailbox_seq;comment:该设备已同步到的 mailbox_events.seq"`
+ DialogScore int64 `gorm:"column:dialog_score;comment:会话列表 Redis ZSET 游标"`
+ UpdatedAt time.Time `gorm:"column:updated_at;comment:游标更新时间"`
+}
+
+func (DeviceSyncCursor) TableName() string { return "device_sync_cursors" }
diff --git a/internal/module/sync/model/mailbox_event.go b/internal/module/sync/model/mailbox_event.go
new file mode 100644
index 0000000..9d0db18
--- /dev/null
+++ b/internal/module/sync/model/mailbox_event.go
@@ -0,0 +1,18 @@
+// Package model is mailbox and device-sync-cursor persistence. One file per table.
+package model
+
+import "time"
+
+// MailboxEvent maps table mailbox_events. Successful online pushes do not write this table.
+type MailboxEvent struct {
+ ID int64 `gorm:"column:id;primaryKey;comment:Snowflake 事件 id"`
+ UserID int64 `gorm:"column:user_id;comment:接收用户"`
+ DeviceID int64 `gorm:"column:device_id;comment:目标设备;0 表示该用户全部设备"`
+ Seq int64 `gorm:"column:seq;comment:该 user+device 上单调递增的邮箱序号"`
+ EventType int16 `gorm:"column:event_type;comment:事件类型:撤回/踢人/成员变更/好友/漏推消息等,不含成功在线推送的新消息"`
+ ConversationID *int64 `gorm:"column:conversation_id;comment:相关会话,可空"`
+ Payload []byte `gorm:"column:payload;comment:控制事件载荷(会话 id+消息 id 等),不含消息正文"`
+ CreatedAt time.Time `gorm:"column:created_at;comment:写入时间"`
+}
+
+func (MailboxEvent) TableName() string { return "mailbox_events" }
diff --git a/internal/module/upload/entity/credential.go b/internal/module/upload/entity/credential.go
new file mode 100644
index 0000000..90ed80f
--- /dev/null
+++ b/internal/module/upload/entity/credential.go
@@ -0,0 +1,17 @@
+// Package entity holds upload credential types. Message content stores the object key only.
+package entity
+
+type Credential struct {
+ ObjectKey string `json:"object_key"`
+ UploadURL string `json:"upload_url"`
+ Method string `json:"method"`
+ Headers map[string]string `json:"headers"`
+ ExpiresIn int64 `json:"expires_in"`
+}
+
+type Download struct {
+ ObjectKey string `json:"object_key"`
+ DownloadURL string `json:"download_url"`
+ Method string `json:"method"`
+ ExpiresIn int64 `json:"expires_in"`
+}
diff --git a/internal/module/upload/entity/purpose.go b/internal/module/upload/entity/purpose.go
new file mode 100644
index 0000000..ae7a48b
--- /dev/null
+++ b/internal/module/upload/entity/purpose.go
@@ -0,0 +1,146 @@
+package entity
+
+import (
+ "fmt"
+ "path"
+ "strings"
+ "unicode"
+)
+
+const (
+ PurposeAvatar = "avatar"
+ PurposeGroupAvatar = "group_avatar"
+ PurposeChatImage = "chat_image"
+ PurposeChatVoice = "chat_voice"
+ PurposeChatVideo = "chat_video"
+ PurposeChatGIF = "chat_gif"
+ PurposeChatFile = "chat_file"
+
+ PrefixPerm = "perm/"
+ PrefixChat = "chat/"
+ PrefixAvatar = "perm/avatar/"
+ PrefixGroup = "perm/group/"
+ PrefixFav = "perm/fav/"
+
+ MethodPUT = "PUT"
+ MethodGET = "GET"
+)
+
+const (
+ maxAvatar = 2 << 20
+ maxImage = 10 << 20
+ maxVoice = 16 << 20
+ maxVideo = 100 << 20
+ maxFile = 50 << 20
+ maxKeyLen = 512
+ extDefault = "bin"
+)
+
+// Spec is the server-owned policy for one upload purpose.
+type Spec struct {
+ Purpose string
+ MaxSize int64
+ MIMEs map[string]struct{}
+}
+
+func LookupSpec(purpose string) (Spec, bool) {
+ s, ok := specs[purpose]
+ return s, ok
+}
+
+func NormalizeMIME(raw string) string {
+ s := strings.ToLower(strings.TrimSpace(raw))
+ s, _, _ = strings.Cut(s, ";")
+ return strings.TrimSpace(s)
+}
+
+func ExtForMIME(mime string) string {
+ if ext, ok := mimeExt[NormalizeMIME(mime)]; ok {
+ return ext
+ }
+ return extDefault
+}
+
+func ExtOfKey(key string) string {
+ base := path.Base(strings.TrimSpace(key))
+ ext := strings.ToLower(strings.TrimPrefix(path.Ext(base), "."))
+ if ext == "" || len(ext) > 8 {
+ return extDefault
+ }
+ for _, r := range ext {
+ if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
+ return extDefault
+ }
+ }
+ return ext
+}
+
+func BuildObjectKey(purpose string, ownerID, objectID int64, day, ext string) string {
+ if ext == "" {
+ ext = extDefault
+ }
+ switch purpose {
+ case PurposeAvatar:
+ return fmt.Sprintf("%s%d/%s/%d.%s", PrefixAvatar, ownerID, day, objectID, ext)
+ case PurposeGroupAvatar:
+ return fmt.Sprintf("%s%d/%d.%s", PrefixGroup, ownerID, objectID, ext)
+ default:
+ return fmt.Sprintf("%s%s/%d/%d.%s", PrefixChat, day, ownerID, objectID, ext)
+ }
+}
+
+func BuildFavKey(userID, objectID int64, day, ext string) string {
+ if ext == "" {
+ ext = extDefault
+ }
+ return fmt.Sprintf("%s%d/%s/%d.%s", PrefixFav, userID, day, objectID, ext)
+}
+
+func ValidObjectKey(key string) bool {
+ if key == "" || len(key) > maxKeyLen {
+ return false
+ }
+ if strings.Contains(key, "..") || strings.ContainsAny(key, " \t\n\\") || strings.HasPrefix(key, "/") {
+ return false
+ }
+ return strings.HasPrefix(key, PrefixPerm) || strings.HasPrefix(key, PrefixChat)
+}
+
+var specs = map[string]Spec{
+ PurposeAvatar: {Purpose: PurposeAvatar, MaxSize: maxAvatar, MIMEs: imageMIMEs},
+ PurposeGroupAvatar: {Purpose: PurposeGroupAvatar, MaxSize: maxAvatar, MIMEs: imageMIMEs},
+ PurposeChatImage: {Purpose: PurposeChatImage, MaxSize: maxImage, MIMEs: imageMIMEs},
+ PurposeChatGIF: {Purpose: PurposeChatGIF, MaxSize: maxImage, MIMEs: gifMIMEs},
+ PurposeChatVoice: {Purpose: PurposeChatVoice, MaxSize: maxVoice, MIMEs: voiceMIMEs},
+ PurposeChatVideo: {Purpose: PurposeChatVideo, MaxSize: maxVideo, MIMEs: videoMIMEs},
+ PurposeChatFile: {Purpose: PurposeChatFile, MaxSize: maxFile, MIMEs: fileMIMEs},
+}
+
+var imageMIMEs = set("image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic", "image/heif")
+var gifMIMEs = set("image/gif", "image/webp")
+var voiceMIMEs = set("audio/mpeg", "audio/mp4", "audio/aac", "audio/amr", "audio/ogg", "audio/wav", "audio/webm", "audio/x-m4a")
+var videoMIMEs = set("video/mp4", "video/quicktime", "video/webm")
+var fileMIMEs = set(
+ "application/pdf", "application/zip", "application/x-zip-compressed",
+ "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "application/octet-stream", "text/plain",
+)
+
+var mimeExt = map[string]string{
+ "image/jpeg": "jpg", "image/jpg": "jpg", "image/png": "png", "image/webp": "webp",
+ "image/heic": "heic", "image/heif": "heif", "image/gif": "gif",
+ "audio/mpeg": "mp3", "audio/mp4": "m4a", "audio/aac": "aac", "audio/amr": "amr",
+ "audio/ogg": "ogg", "audio/wav": "wav", "audio/webm": "weba", "audio/x-m4a": "m4a",
+ "video/mp4": "mp4", "video/quicktime": "mov", "video/webm": "webm",
+ "application/pdf": "pdf", "application/zip": "zip", "application/x-zip-compressed": "zip",
+ "application/octet-stream": extDefault, "text/plain": "txt",
+}
+
+func set(values ...string) map[string]struct{} {
+ m := make(map[string]struct{}, len(values))
+ for _, v := range values {
+ m[v] = struct{}{}
+ }
+ return m
+}
diff --git a/internal/module/upload/entity/purpose_test.go b/internal/module/upload/entity/purpose_test.go
new file mode 100644
index 0000000..71830a4
--- /dev/null
+++ b/internal/module/upload/entity/purpose_test.go
@@ -0,0 +1,55 @@
+package entity
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestBuildObjectKeyByPurpose(t *testing.T) {
+ avatar := BuildObjectKey(PurposeAvatar, 11, 99, "20260917", "jpg")
+ if avatar != "perm/avatar/11/20260917/99.jpg" {
+ t.Fatalf("avatar %s", avatar)
+ }
+ chat := BuildObjectKey(PurposeChatImage, 11, 99, "20260917", "jpg")
+ if chat != "chat/20260917/11/99.jpg" {
+ t.Fatalf("chat %s", chat)
+ }
+ group := BuildObjectKey(PurposeGroupAvatar, 7, 99, "20260917", "png")
+ if group != "perm/group/7/99.png" {
+ t.Fatalf("group %s", group)
+ }
+ fav := BuildFavKey(11, 7, "20260917", "m4a")
+ if fav != "perm/fav/11/20260917/7.m4a" {
+ t.Fatalf("fav %s", fav)
+ }
+}
+
+func TestValidObjectKey(t *testing.T) {
+ if !ValidObjectKey("chat/20260917/1/2.jpg") || !ValidObjectKey("perm/avatar/1/2.jpg") || !ValidObjectKey("perm/group/3/4.jpg") {
+ t.Fatal("want valid")
+ }
+ for _, key := range []string{"", "../x", "/chat/a", "other/a", "chat/a b.jpg", "chat/.. /x"} {
+ if ValidObjectKey(key) {
+ t.Fatalf("accepted %q", key)
+ }
+ }
+}
+
+func TestNormalizeMIMEAndExt(t *testing.T) {
+ if got := NormalizeMIME("Image/JPEG; charset=utf-8"); got != "image/jpeg" {
+ t.Fatalf("mime %s", got)
+ }
+ if ExtForMIME("image/png") != "png" || ExtOfKey("chat/a/b.M4A") != "m4a" {
+ t.Fatal("ext")
+ }
+ spec, ok := LookupSpec(PurposeChatVoice)
+ if !ok || spec.MaxSize <= 0 {
+ t.Fatal("spec")
+ }
+ if _, ok := spec.MIMEs["audio/mpeg"]; !ok {
+ t.Fatal("voice mime")
+ }
+ if strings.Contains(BuildObjectKey(PurposeAvatar, 1, 1, "d", "jpg"), PrefixChat) {
+ t.Fatal("avatar must be perm")
+ }
+}
diff --git a/internal/module/upload/logic/credential.go b/internal/module/upload/logic/credential.go
new file mode 100644
index 0000000..f1a4cea
--- /dev/null
+++ b/internal/module/upload/logic/credential.go
@@ -0,0 +1,90 @@
+package logic
+
+import (
+ "context"
+
+ "spark-server/internal/module/upload/entity"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
+ "spark-server/internal/pkg/reqctx"
+ "spark-server/internal/pkg/validate"
+)
+
+// CreateInput requests a single-object Presigned PUT. The client does not choose the key.
+type CreateInput struct {
+ Purpose string `json:"purpose" validate:"required" label:"field.purpose" errmsg:"required:validate.upload.purpose_required"`
+ Mime string `json:"mime" validate:"required,max=128" label:"field.mime" errmsg:"required:validate.upload.mime_required;max:validate.upload.mime_invalid"`
+ Size int64 `json:"size" validate:"required,gt=0" label:"field.size" errmsg:"required:validate.upload.size_required;gt:validate.upload.size_invalid"`
+ GroupID int64 `json:"group_id" validate:"omitempty,gt=0" label:"field.group_id"`
+}
+
+func (s *Service) CreateCredential(ctx context.Context, userID int64, in *CreateInput) (*entity.Credential, error) {
+ if !s.ready() {
+ return nil, errs.ErrAvailable
+ }
+ if 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 !s.allow(userID) {
+ return nil, errs.ErrRateLimited
+ }
+ _, mime, err := matchSpec(ctx, in)
+ if err != nil {
+ return nil, err
+ }
+ objectID, err := s.ids.NextID()
+ if err != nil {
+ return nil, err
+ }
+ ownerID := userID
+ if in.Purpose == entity.PurposeGroupAvatar {
+ if err := s.readyGroupAvatar(ctx, userID, in.GroupID); err != nil {
+ return nil, err
+ }
+ ownerID = in.GroupID
+ }
+ key := entity.BuildObjectKey(in.Purpose, ownerID, objectID, dayOf(s.now()), entity.ExtForMIME(mime))
+ url, err := s.store.PresignPut(ctx, key, mime, s.ttl)
+ if err != nil {
+ return nil, err
+ }
+ return &entity.Credential{
+ ObjectKey: key,
+ UploadURL: url,
+ Method: entity.MethodPUT,
+ Headers: map[string]string{"Content-Type": mime},
+ ExpiresIn: s.expiresIn(),
+ }, nil
+}
+
+func matchSpec(ctx context.Context, in *CreateInput) (entity.Spec, string, error) {
+ lang := reqctx.Lang(ctx)
+ spec, ok := entity.LookupSpec(in.Purpose)
+ if !ok {
+ return entity.Spec{}, "", errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyUploadPurposeInvalid, nil))
+ }
+ mime := entity.NormalizeMIME(in.Mime)
+ if _, ok := spec.MIMEs[mime]; !ok {
+ return entity.Spec{}, "", errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyUploadMIMEInvalid, nil))
+ }
+ if in.Size > spec.MaxSize {
+ return entity.Spec{}, "", errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyUploadSizeExceeded, nil))
+ }
+ return spec, mime, nil
+}
+
+func (s *Service) readyGroupAvatar(ctx context.Context, userID, groupID int64) error {
+ if groupID <= 0 {
+ return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyUploadGroupRequired, nil))
+ }
+ if s.groups == nil {
+ return nil
+ }
+ return s.groups.CanSetAvatar(ctx, userID, groupID)
+}
diff --git a/internal/module/upload/logic/credential_test.go b/internal/module/upload/logic/credential_test.go
new file mode 100644
index 0000000..e09c11c
--- /dev/null
+++ b/internal/module/upload/logic/credential_test.go
@@ -0,0 +1,97 @@
+package logic
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "spark-server/internal/infrastructure/id"
+ "spark-server/internal/module/upload/entity"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/reqctx"
+)
+
+type fakeStore struct {
+ put, get string
+ putErr error
+}
+
+func (f *fakeStore) PresignPut(_ context.Context, key, _ string, _ time.Duration) (string, error) {
+ if f.putErr != nil {
+ return "", f.putErr
+ }
+ if f.put != "" {
+ return f.put, nil
+ }
+ return "http://rustfs.test/" + key + "?sig=1", nil
+}
+
+func (f *fakeStore) PresignGet(_ context.Context, key string, _ time.Duration) (string, error) {
+ if f.get != "" {
+ return f.get, nil
+ }
+ return "http://rustfs.test/" + key + "?get=1", nil
+}
+
+func testUpload(t *testing.T) *Service {
+ t.Helper()
+ ids, err := id.New(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return NewService(&fakeStore{}, ids, Options{
+ TTL: 5 * time.Minute,
+ Now: func() time.Time { return time.Date(2026, 9, 17, 8, 0, 0, 0, time.UTC) },
+ })
+}
+
+func TestCreateCredentialChatVsAvatar(t *testing.T) {
+ svc := testUpload(t)
+ ctx := reqctx.With(t.Context(), &reqctx.RequestContext{UserID: 42, Language: "zh-CN"})
+ chat, err := svc.CreateCredential(ctx, 42, &CreateInput{Purpose: entity.PurposeChatImage, Mime: "image/jpeg", Size: 1024})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(chat.ObjectKey, "chat/20260917/42/") || chat.Method != "PUT" || chat.Headers["Content-Type"] != "image/jpeg" {
+ t.Fatalf("%+v", chat)
+ }
+ avatar, err := svc.CreateCredential(ctx, 42, &CreateInput{Purpose: entity.PurposeAvatar, Mime: "image/png", Size: 2048})
+ if err != nil || !strings.HasPrefix(avatar.ObjectKey, "perm/avatar/42/20260917/") {
+ t.Fatalf("%+v %v", avatar, err)
+ }
+ group, err := svc.CreateCredential(ctx, 42, &CreateInput{Purpose: entity.PurposeGroupAvatar, Mime: "image/jpeg", Size: 1024, GroupID: 7})
+ if err != nil || !strings.HasPrefix(group.ObjectKey, "perm/group/7/") {
+ t.Fatalf("%+v %v", group, err)
+ }
+ if _, err := svc.CreateCredential(ctx, 42, &CreateInput{Purpose: entity.PurposeGroupAvatar, Mime: "image/jpeg", Size: 1024}); err == nil {
+ t.Fatal("group avatar without group_id")
+ }
+}
+
+func TestCreateCredentialRejectsBadMIMEAndSize(t *testing.T) {
+ svc := testUpload(t)
+ ctx := reqctx.With(t.Context(), &reqctx.RequestContext{UserID: 1, Language: "zh-CN"})
+ if _, err := svc.CreateCredential(ctx, 1, &CreateInput{Purpose: entity.PurposeAvatar, Mime: "application/pdf", Size: 100}); err == nil {
+ t.Fatal("pdf avatar")
+ }
+ if _, err := svc.CreateCredential(ctx, 1, &CreateInput{Purpose: entity.PurposeChatImage, Mime: "image/jpeg", Size: 50 << 20}); err == nil {
+ t.Fatal("huge image")
+ }
+ if _, err := svc.CreateCredential(ctx, 0, &CreateInput{Purpose: entity.PurposeChatImage, Mime: "image/jpeg", Size: 1}); !errors.Is(err, errs.ErrUnauthorized) {
+ t.Fatalf("uid %v", err)
+ }
+}
+
+func TestCreateDownloadValidatesKey(t *testing.T) {
+ svc := testUpload(t)
+ ctx := reqctx.With(t.Context(), &reqctx.RequestContext{UserID: 1, Language: "en"})
+ got, err := svc.CreateDownload(ctx, 1, &DownloadInput{ObjectKey: "chat/20260917/1/2.jpg"})
+ if err != nil || got.Method != "GET" || !strings.Contains(got.DownloadURL, "chat/20260917/1/2.jpg") {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if _, err := svc.CreateDownload(ctx, 1, &DownloadInput{ObjectKey: "../secret"}); err == nil {
+ t.Fatal("path escape")
+ }
+}
diff --git a/internal/module/upload/logic/download.go b/internal/module/upload/logic/download.go
new file mode 100644
index 0000000..174d540
--- /dev/null
+++ b/internal/module/upload/logic/download.go
@@ -0,0 +1,48 @@
+package logic
+
+import (
+ "context"
+
+ "spark-server/internal/module/upload/entity"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
+ "spark-server/internal/pkg/reqctx"
+ "spark-server/internal/pkg/validate"
+)
+
+// DownloadInput requests a short-lived GET URL for an already-uploaded object key.
+type DownloadInput struct {
+ ObjectKey string `json:"object_key" validate:"required,max=512" label:"field.object_key" errmsg:"required:validate.upload.key_required;max:validate.upload.key_invalid"`
+}
+
+func (s *Service) CreateDownload(ctx context.Context, userID int64, in *DownloadInput) (*entity.Download, error) {
+ if !s.ready() {
+ return nil, errs.ErrAvailable
+ }
+ if 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 !s.allow(userID) {
+ return nil, errs.ErrRateLimited
+ }
+ key := in.ObjectKey
+ if !entity.ValidObjectKey(key) {
+ return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyUploadKeyInvalid, nil))
+ }
+ url, err := s.store.PresignGet(ctx, key, s.ttl)
+ if err != nil {
+ return nil, err
+ }
+ return &entity.Download{
+ ObjectKey: key,
+ DownloadURL: url,
+ Method: entity.MethodGET,
+ ExpiresIn: s.expiresIn(),
+ }, nil
+}
diff --git a/internal/module/upload/logic/service.go b/internal/module/upload/logic/service.go
new file mode 100644
index 0000000..9a6983a
--- /dev/null
+++ b/internal/module/upload/logic/service.go
@@ -0,0 +1,72 @@
+package logic
+
+import (
+ "context"
+ "strconv"
+ "time"
+
+ "spark-server/internal/pkg/ratelimit"
+)
+
+// Presigner issues short-lived object-storage URLs. Infrastructure implements it.
+type Presigner interface {
+ PresignPut(ctx context.Context, key, contentType string, ttl time.Duration) (string, error)
+ PresignGet(ctx context.Context, key string, ttl time.Duration) (string, error)
+}
+
+type IDGen interface {
+ NextID() (int64, error)
+}
+
+// AvatarACL gates group_avatar uploads. Nil skips the check (tests).
+type AvatarACL interface {
+ CanSetAvatar(ctx context.Context, userID, groupID int64) error
+}
+
+type Options struct {
+ TTL time.Duration
+ Limit *ratelimit.Limiter
+ Now func() time.Time
+ Groups AvatarACL
+}
+
+// Service issues upload/download credentials. HTTP and TCP share it.
+type Service struct {
+ store Presigner
+ ids IDGen
+ ttl time.Duration
+ limit *ratelimit.Limiter
+ now func() time.Time
+ groups AvatarACL
+}
+
+func NewService(store Presigner, ids IDGen, opt Options) *Service {
+ ttl := opt.TTL
+ if ttl <= 0 {
+ ttl = 15 * time.Minute
+ }
+ clock := opt.Now
+ if clock == nil {
+ clock = func() time.Time { return time.Now().UTC() }
+ }
+ return &Service{store: store, ids: ids, ttl: ttl, limit: opt.Limit, now: clock, groups: opt.Groups}
+}
+
+func (s *Service) ready() bool {
+ return s != nil && s.store != nil && s.ids != nil
+}
+
+func (s *Service) expiresIn() int64 {
+ return int64(s.ttl / time.Second)
+}
+
+func (s *Service) allow(userID int64) bool {
+ if s.limit == nil {
+ return true
+ }
+ return s.limit.Allow("upload:" + strconv.FormatInt(userID, 10))
+}
+
+func dayOf(t time.Time) string {
+ return t.UTC().Format("20060102")
+}
diff --git a/internal/module/upload/service.go b/internal/module/upload/service.go
deleted file mode 100644
index 9e10e21..0000000
--- a/internal/module/upload/service.go
+++ /dev/null
@@ -1,12 +0,0 @@
-package upload
-
-import "context"
-
-type Credential struct {
- ObjectKey string
- UploadURL string
-}
-
-type Service interface {
- CreateCredential(ctx context.Context, userID int64, mime string, size int64) (*Credential, error)
-}
diff --git a/internal/module/user/entity/avatar.go b/internal/module/user/entity/avatar.go
new file mode 100644
index 0000000..c06cdd4
--- /dev/null
+++ b/internal/module/user/entity/avatar.go
@@ -0,0 +1,168 @@
+package entity
+
+import (
+ "bytes"
+ "encoding/base64"
+ "fmt"
+ "hash/adler32"
+ "image"
+ "image/color"
+ "image/draw"
+ "image/png"
+ "math"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+)
+
+const (
+ avatarSize = 256
+ avatarSat = 0.3
+ avatarVal = 0.9
+ glyphW = 5
+ glyphH = 7
+ glyphPx = 20
+)
+
+// LetterAvatarPNG draws a first-letter avatar. Hue follows FastAdmin letter_avatar (adler32 % 360).
+func LetterAvatarPNG(text string) ([]byte, error) {
+ img := image.NewRGBA(image.Rect(0, 0, avatarSize, avatarSize))
+ bg := AvatarColor(text)
+ draw.Draw(img, img.Bounds(), &image.Uniform{C: bg}, image.Point{}, draw.Src)
+ if letter := AvatarLetter(text); letter != "" {
+ if rows, ok := avatarGlyphs[letter[0]]; ok {
+ blitGlyph(img, rows, color.White)
+ } else {
+ fillPerson(img, color.White)
+ }
+ } else {
+ fillPerson(img, color.White)
+ }
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, img); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+const avatarDataURIPrefix = "data:image/svg+xml;base64,"
+
+// LetterAvatarDataURI is a compact SVG fallback (FastAdmin letter_avatar). Always fits users.avatar VARCHAR(512).
+func LetterAvatarDataURI(text string) string {
+ c := AvatarColor(text)
+ fill := fmt.Sprintf("rgb(%d,%d,%d)", c.R, c.G, c.B)
+ var svg string
+ if letter := AvatarLetter(text); letter != "" {
+ svg = fmt.Sprintf(``, fill, letter)
+ } else {
+ svg = fmt.Sprintf(``, fill)
+ }
+ return avatarDataURIPrefix + base64.StdEncoding.EncodeToString([]byte(svg))
+}
+
+// AvatarLetter is the uppercase first rune. Non-Latin glyphs use the silhouette fallback.
+func AvatarLetter(text string) string {
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return "?"
+ }
+ r, _ := utf8.DecodeRuneInString(text)
+ if r == utf8.RuneError {
+ return "?"
+ }
+ r = unicode.ToUpper(r)
+ if r > unicode.MaxASCII || !unicode.IsLetter(r) && !unicode.IsDigit(r) {
+ return ""
+ }
+ return string(r)
+}
+
+// AvatarColor is the pastel background from adler32(text) % 360, sat 0.3 / val 0.9.
+func AvatarColor(text string) color.RGBA {
+ hue := float64(adler32.Checksum([]byte(text))%360) / 360
+ return hsvRGB(hue, avatarSat, avatarVal)
+}
+
+func blitGlyph(img *image.RGBA, rows [glyphH]byte, c color.Color) {
+ gw, gh := glyphW*glyphPx, glyphH*glyphPx
+ ox := (avatarSize - gw) / 2
+ oy := (avatarSize - gh) / 2
+ for y := 0; y < glyphH; y++ {
+ row := rows[y]
+ for x := 0; x < glyphW; x++ {
+ if row&(1< 512 {
+ t.Fatalf("%q len %d", name, len(got))
+ }
+ }
+ a, b := LetterAvatarDataURI("test01"), LetterAvatarDataURI("ada")
+ if a == b {
+ t.Fatal("same uri")
+ }
+}
diff --git a/internal/module/user/entity/device.go b/internal/module/user/entity/device.go
new file mode 100644
index 0000000..3c83794
--- /dev/null
+++ b/internal/module/user/entity/device.go
@@ -0,0 +1,68 @@
+package entity
+
+import (
+ "strings"
+ "time"
+)
+
+const (
+ DeviceIOS = "ios"
+ DeviceAndroid = "android"
+ DeviceHarmony = "harmony"
+ DeviceWindows = "windows"
+ DeviceMacOS = "macos"
+ DeviceWeb = "web"
+)
+
+var deviceTypes = map[string]struct{}{
+ DeviceIOS: {},
+ DeviceAndroid: {},
+ DeviceHarmony: {},
+ DeviceWindows: {},
+ DeviceMacOS: {},
+ DeviceWeb: {},
+}
+
+// Device is a login device row for one user.
+type Device struct {
+ ID int64 // table AUTO_INCREMENT; business key is (UserID, DeviceID)
+ UserID int64
+ DeviceID uint64
+ DeviceType string
+ DeviceName string
+ AppVersion string
+ Language string
+ LastIP string
+ LastLoginAt time.Time
+ CreatedAt time.Time
+}
+
+// LoginDevice is the device snapshot supplied at login (not a table row).
+type LoginDevice struct {
+ DeviceID uint64
+ DeviceType string
+ Platform string // alias of DeviceType; HTTP still accepts `platform`
+ DeviceName string
+ AppVersion string
+ Language string
+ RealIP string
+}
+
+func NormalizeDeviceType(s string) string {
+ return strings.ToLower(strings.TrimSpace(s))
+}
+
+func ValidDeviceType(s string) bool {
+ _, ok := deviceTypes[NormalizeDeviceType(s)]
+ return ok
+}
+
+func DeviceTypeOf(d *LoginDevice) string {
+ if d == nil {
+ return ""
+ }
+ if t := NormalizeDeviceType(d.DeviceType); ValidDeviceType(t) {
+ return t
+ }
+ return NormalizeDeviceType(d.Platform)
+}
diff --git a/internal/module/user/entity/email.go b/internal/module/user/entity/email.go
new file mode 100644
index 0000000..23949e1
--- /dev/null
+++ b/internal/module/user/entity/email.go
@@ -0,0 +1,24 @@
+package entity
+
+import (
+ "regexp"
+ "strings"
+ "time"
+)
+
+var testEmailRe = regexp.MustCompile(`^test(0[1-9]|10)@spark\.com$`)
+
+// EmailBinding maps one normalized email to one user.
+type EmailBinding struct {
+ Email string
+ UserID int64
+ CreatedAt time.Time
+}
+
+func NormalizeEmail(email string) string {
+ return strings.ToLower(strings.TrimSpace(email))
+}
+
+func IsTestEmail(email string) bool {
+ return testEmailRe.MatchString(NormalizeEmail(email))
+}
diff --git a/internal/module/user/entity/email_test.go b/internal/module/user/entity/email_test.go
new file mode 100644
index 0000000..a4a3a23
--- /dev/null
+++ b/internal/module/user/entity/email_test.go
@@ -0,0 +1,18 @@
+package entity
+
+import "testing"
+
+func TestIsTestEmail(t *testing.T) {
+ ok := []string{"test01@spark.com", "test10@spark.com", "TEST05@SPARK.COM"}
+ for _, e := range ok {
+ if !IsTestEmail(e) {
+ t.Fatalf("want test %s", e)
+ }
+ }
+ bad := []string{"test00@spark.com", "test11@spark.com", "test1@spark.com", "other@spark.com", "test01@example.com"}
+ for _, e := range bad {
+ if IsTestEmail(e) {
+ t.Fatalf("not test %s", e)
+ }
+ }
+}
diff --git a/internal/module/user/entity/login_log.go b/internal/module/user/entity/login_log.go
new file mode 100644
index 0000000..897172f
--- /dev/null
+++ b/internal/module/user/entity/login_log.go
@@ -0,0 +1,16 @@
+package entity
+
+import "time"
+
+// LoginLog is one successful login event (append-only).
+type LoginLog struct {
+ ID int64
+ UserID int64
+ DeviceID uint64
+ DeviceType string
+ DeviceName string
+ AppVersion string
+ Language string
+ IP string
+ CreatedAt time.Time
+}
diff --git a/internal/module/user/entity/qrcode.go b/internal/module/user/entity/qrcode.go
new file mode 100644
index 0000000..e23a137
--- /dev/null
+++ b/internal/module/user/entity/qrcode.go
@@ -0,0 +1,31 @@
+package entity
+
+import (
+ "strings"
+
+ "github.com/teris-io/shortid"
+)
+
+const (
+ qrTokenMin = 9
+ qrTokenMax = 20
+)
+
+// NewQRCode is an opaque scan ticket from shortid. It is not uid, spark_id, or a Redis key.
+func NewQRCode() (string, error) {
+ return shortid.Generate()
+}
+
+func ValidQRCode(s string) bool {
+ s = strings.TrimSpace(s)
+ n := len(s)
+ if n < qrTokenMin || n > qrTokenMax {
+ return false
+ }
+ for i := 0; i < n; i++ {
+ if !strings.ContainsRune(shortid.DefaultABC, rune(s[i])) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/module/user/entity/qrcode_test.go b/internal/module/user/entity/qrcode_test.go
new file mode 100644
index 0000000..63a2b74
--- /dev/null
+++ b/internal/module/user/entity/qrcode_test.go
@@ -0,0 +1,23 @@
+package entity
+
+import "testing"
+
+func TestNewQRCodeOpaque(t *testing.T) {
+ got, err := NewQRCode()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !ValidQRCode(got) {
+ t.Fatalf("%s", got)
+ }
+ other, err := NewQRCode()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got == other {
+ t.Fatal("not random")
+ }
+ if ValidQRCode("spark:u:1") || ValidQRCode("spark:q:zz") || ValidQRCode("") {
+ t.Fatal("invalid accepted")
+ }
+}
diff --git a/internal/module/user/entity/spark_id.go b/internal/module/user/entity/spark_id.go
new file mode 100644
index 0000000..6072a69
--- /dev/null
+++ b/internal/module/user/entity/spark_id.go
@@ -0,0 +1,48 @@
+package entity
+
+import (
+ "regexp"
+ "strings"
+)
+
+const (
+ SparkIDMin = 6
+ SparkIDMax = 32
+)
+
+var sparkIDRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]{5,31}$`)
+
+func ValidSparkID(id string) bool {
+ n := len(id)
+ return n >= SparkIDMin && n <= SparkIDMax && sparkIDRe.MatchString(id)
+}
+
+// DefaultSparkID is a unique, letter-prefixed handle derived from the Snowflake id.
+func DefaultSparkID(userID int64) string {
+ s := "s" + strings.ToLower(strconv36(userID))
+ for len(s) < SparkIDMin {
+ s += "0"
+ }
+ if len(s) > SparkIDMax {
+ s = s[:SparkIDMax]
+ }
+ return s
+}
+
+func strconv36(n int64) string {
+ if n < 0 {
+ n = -n
+ }
+ const digits = "0123456789abcdefghijklmnopqrstuvwxyz"
+ if n == 0 {
+ return "0"
+ }
+ var buf [16]byte
+ i := len(buf)
+ for n > 0 {
+ i--
+ buf[i] = digits[n%36]
+ n /= 36
+ }
+ return string(buf[i:])
+}
diff --git a/internal/module/user/entity/spark_id_test.go b/internal/module/user/entity/spark_id_test.go
new file mode 100644
index 0000000..597a810
--- /dev/null
+++ b/internal/module/user/entity/spark_id_test.go
@@ -0,0 +1,14 @@
+package entity
+
+import "testing"
+
+func TestDefaultSparkID(t *testing.T) {
+ a := DefaultSparkID(100)
+ b := DefaultSparkID(101)
+ if !ValidSparkID(a) || !ValidSparkID(b) || a == b || a[0] != 's' {
+ t.Fatalf("%s %s", a, b)
+ }
+ if !ValidSparkID("alice_01") || ValidSparkID("1abcde") || ValidSparkID("ab") {
+ t.Fatal("spark_id regex")
+ }
+}
diff --git a/internal/module/user/entity/user.go b/internal/module/user/entity/user.go
new file mode 100644
index 0000000..e719955
--- /dev/null
+++ b/internal/module/user/entity/user.go
@@ -0,0 +1,32 @@
+// Package entity holds user domain types. Persistence belongs in model.
+package entity
+
+import "time"
+
+const StatusActive = 1
+
+const (
+ GenderUnknown = 0
+ GenderMale = 1
+ GenderFemale = 2
+)
+
+func ValidGender(g int) bool {
+ return g == GenderUnknown || g == GenderMale || g == GenderFemale
+}
+
+// User is the account. ID is uid (Snowflake), never AUTO_INCREMENT.
+type User struct {
+ ID int64
+ SparkID string
+ Nickname string
+ Avatar string
+ QRCode string
+ Birthday time.Time
+ Gender int
+ Status int
+ LastLoginAt time.Time
+ LastActiveAt time.Time
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
diff --git a/internal/module/user/logic/avatar.go b/internal/module/user/logic/avatar.go
new file mode 100644
index 0000000..ee02231
--- /dev/null
+++ b/internal/module/user/logic/avatar.go
@@ -0,0 +1,61 @@
+package logic
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ uploadentity "spark-server/internal/module/upload/entity"
+ "spark-server/internal/module/user/entity"
+)
+
+const defaultAvatarMIME = "image/png"
+
+func (s *Service) ensureDefaultAvatar(ctx context.Context, u *entity.User) {
+ if u == nil || strings.TrimSpace(u.Avatar) != "" {
+ return
+ }
+ s.stampDefaultAvatar(ctx, u)
+}
+
+func (s *Service) stampDefaultAvatar(ctx context.Context, u *entity.User) {
+ if u == nil || u.ID <= 0 {
+ return
+ }
+ fallback := entity.LetterAvatarDataURI(u.Nickname)
+ avatar := fallback
+ if key := s.putLetterPNG(ctx, u); key != "" {
+ avatar = key
+ }
+ prev := u.Avatar
+ u.Avatar = avatar
+ if s.repo == nil || prev == avatar {
+ return
+ }
+ if err := s.repo.UpdateProfile(ctx, u); err == nil {
+ return
+ }
+ u.Avatar = fallback
+ if prev != fallback {
+ _ = s.repo.UpdateProfile(ctx, u)
+ }
+}
+
+func (s *Service) putLetterPNG(ctx context.Context, u *entity.User) string {
+ if s == nil || s.objects == nil || s.ids == nil || u == nil || u.ID <= 0 {
+ return ""
+ }
+ raw, err := entity.LetterAvatarPNG(u.Nickname)
+ if err != nil || len(raw) == 0 {
+ return ""
+ }
+ objectID, err := s.ids.NextID()
+ if err != nil {
+ return ""
+ }
+ key := uploadentity.BuildObjectKey(uploadentity.PurposeAvatar, u.ID, objectID, time.Now().Format("20060102"), "png")
+ if err := s.objects.Put(ctx, key, raw, defaultAvatarMIME); err != nil {
+ return ""
+ }
+ return key
+}
diff --git a/internal/module/user/logic/email_code.go b/internal/module/user/logic/email_code.go
new file mode 100644
index 0000000..c211da6
--- /dev/null
+++ b/internal/module/user/logic/email_code.go
@@ -0,0 +1,67 @@
+package logic
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/binary"
+ "fmt"
+
+ "spark-server/internal/module/user/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+const (
+ codeTTLSeconds = 300
+ codeCooldownSeconds = 60
+)
+
+// EmailCodeTTLSeconds is the OTP lifetime returned to clients.
+const EmailCodeTTLSeconds = codeTTLSeconds
+
+// CodeStore is the email OTP cache (Redis).
+type CodeStore interface {
+ Put(ctx context.Context, email, code string, ttlSeconds int) error
+ Get(ctx context.Context, email string) (string, error)
+ Del(ctx context.Context, email string) error
+ SetCooldown(ctx context.Context, email string, ttlSeconds int) (bool, error)
+}
+
+func (s *Service) SendEmailCode(ctx context.Context, email string) error {
+ if s == nil || s.codes == nil {
+ return errs.ErrAvailable
+ }
+ email = entity.NormalizeEmail(email)
+ if email == "" {
+ return errs.ErrInvalidArgument
+ }
+ if entity.IsTestEmail(email) {
+ return nil
+ }
+ ok, err := s.codes.SetCooldown(ctx, email, codeCooldownSeconds)
+ if err != nil {
+ return err
+ }
+ if !ok {
+ return errs.ErrRateLimited
+ }
+ code, err := randomDigits(6)
+ if err != nil {
+ return errs.ErrInner.WithError("email-code", err)
+ }
+ return s.codes.Put(ctx, email, code, codeTTLSeconds)
+}
+
+func randomDigits(n int) (string, error) {
+ if n <= 0 {
+ return "", fmt.Errorf("digits")
+ }
+ var buf [8]byte
+ out := make([]byte, n)
+ for i := 0; i < n; i++ {
+ if _, err := rand.Read(buf[:4]); err != nil {
+ return "", err
+ }
+ out[i] = '0' + byte(binary.BigEndian.Uint32(buf[:4])%10)
+ }
+ return string(out), nil
+}
diff --git a/internal/module/user/logic/email_code_test.go b/internal/module/user/logic/email_code_test.go
new file mode 100644
index 0000000..0886fdb
--- /dev/null
+++ b/internal/module/user/logic/email_code_test.go
@@ -0,0 +1,21 @@
+package logic
+
+import (
+ "errors"
+ "testing"
+
+ "spark-server/internal/pkg/errs"
+)
+
+func TestSendEmailCodeCooldown(t *testing.T) {
+ svc, _, _ := testService(t)
+ if err := svc.SendEmailCode(t.Context(), "test06@spark.com"); err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.SendEmailCode(t.Context(), "ada@example.com"); err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.SendEmailCode(t.Context(), "ada@example.com"); !errors.Is(err, errs.ErrRateLimited) {
+ t.Fatalf("cooldown %v", err)
+ }
+}
diff --git a/internal/module/user/logic/find.go b/internal/module/user/logic/find.go
new file mode 100644
index 0000000..9d0580b
--- /dev/null
+++ b/internal/module/user/logic/find.go
@@ -0,0 +1,31 @@
+package logic
+
+import (
+ "context"
+ "strings"
+
+ "spark-server/internal/module/user/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+func (s *Service) GetByEmail(ctx context.Context, email string) (*entity.User, error) {
+ if s == nil || s.repo == nil {
+ return nil, errs.ErrNotFound
+ }
+ email = entity.NormalizeEmail(email)
+ if email == "" {
+ return nil, errs.ErrNotFound
+ }
+ return s.repo.GetByEmail(ctx, email)
+}
+
+func (s *Service) GetBySparkID(ctx context.Context, sparkID string) (*entity.User, error) {
+ if s == nil || s.repo == nil {
+ return nil, errs.ErrNotFound
+ }
+ sparkID = strings.TrimSpace(sparkID)
+ if sparkID == "" {
+ return nil, errs.ErrNotFound
+ }
+ return s.repo.GetBySparkID(ctx, sparkID)
+}
diff --git a/internal/module/user/logic/login.go b/internal/module/user/logic/login.go
new file mode 100644
index 0000000..e512986
--- /dev/null
+++ b/internal/module/user/logic/login.go
@@ -0,0 +1,191 @@
+package logic
+
+import (
+ "context"
+ "crypto/subtle"
+ "errors"
+ "strings"
+ "time"
+
+ "spark-server/internal/module/user/entity"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
+ "spark-server/internal/pkg/reqctx"
+)
+
+// AuthenticateEmail verifies OTP (test01–test10@spark.com accept any code),
+// creates the user on first login, and upserts the login device.
+func (s *Service) AuthenticateEmail(ctx context.Context, email, verifyCode string, device *entity.LoginDevice) (*entity.User, error) {
+ if s == nil || s.repo == nil || s.ids == nil {
+ return nil, errs.ErrUnauthorized
+ }
+ email = entity.NormalizeEmail(email)
+ if email == "" {
+ return nil, errs.ErrUnauthorized
+ }
+ if err := s.checkVerifyCode(ctx, email, verifyCode); err != nil {
+ return nil, err
+ }
+ u, err := s.getOrCreate(ctx, email)
+ if err != nil {
+ return nil, err
+ }
+ if err := s.ensureQRCode(ctx, u); err != nil {
+ return nil, err
+ }
+ s.ensureDefaultAvatar(ctx, u)
+ if u.Status != entity.StatusActive {
+ return nil, errs.ErrForbidden
+ }
+ if err := s.touchDevice(ctx, u.ID, device); err != nil {
+ return nil, err
+ }
+ if err := s.recordLogin(ctx, u.ID, device); err != nil {
+ return nil, err
+ }
+ now := time.Now()
+ if err := s.repo.TouchLogin(ctx, u.ID, now); err != nil {
+ return nil, err
+ }
+ u.LastLoginAt = now
+ u.LastActiveAt = now
+ return u, nil
+}
+
+func (s *Service) checkVerifyCode(ctx context.Context, email, verifyCode string) error {
+ if entity.IsTestEmail(email) {
+ return nil
+ }
+ want, err := s.codes.Get(ctx, email)
+ if err != nil {
+ return err
+ }
+ if want == "" || subtle.ConstantTimeCompare([]byte(want), []byte(verifyCode)) != 1 {
+ return errs.ErrUnauthorized
+ }
+ _ = s.codes.Del(ctx, email)
+ return nil
+}
+
+func (s *Service) getOrCreate(ctx context.Context, email string) (*entity.User, error) {
+ u, err := s.repo.GetByEmail(ctx, email)
+ if err == nil {
+ return u, nil
+ }
+ if !errors.Is(err, errs.ErrNotFound) {
+ return nil, err
+ }
+ return s.createWithEmail(ctx, email)
+}
+
+func (s *Service) createWithEmail(ctx context.Context, email string) (*entity.User, error) {
+ id, err := s.ids.NextID()
+ if err != nil {
+ return nil, err
+ }
+ now := time.Now()
+ qr, err := entity.NewQRCode()
+ if err != nil {
+ return nil, err
+ }
+ nick := nicknameFromEmail(email)
+ u := &entity.User{
+ ID: id,
+ SparkID: entity.DefaultSparkID(id),
+ Nickname: nick,
+ Avatar: entity.LetterAvatarDataURI(nick),
+ Gender: entity.GenderUnknown,
+ Status: entity.StatusActive,
+ QRCode: qr,
+ LastLoginAt: now,
+ LastActiveAt: now,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := s.repo.CreateWithEmail(ctx, u, email); err != nil {
+ if errors.Is(err, errs.ErrDuplicate) {
+ return s.repo.GetByEmail(ctx, email)
+ }
+ return nil, err
+ }
+ s.stampDefaultAvatar(ctx, u)
+ return u, nil
+}
+
+func (s *Service) ensureQRCode(ctx context.Context, u *entity.User) error {
+ if u == nil || entity.ValidQRCode(u.QRCode) {
+ return nil
+ }
+ qr, err := entity.NewQRCode()
+ if err != nil {
+ return err
+ }
+ u.QRCode = qr
+ return s.repo.UpdateQRCode(ctx, u.ID, u.QRCode)
+}
+
+func (s *Service) touchDevice(ctx context.Context, userID int64, device *entity.LoginDevice) error {
+ if device == nil || device.DeviceID == 0 {
+ return errs.ErrInvalidArgument
+ }
+ typ := entity.DeviceTypeOf(device)
+ if !entity.ValidDeviceType(typ) {
+ return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginDeviceTypeRequired, nil))
+ }
+ now := time.Now()
+ ip := strings.TrimSpace(device.RealIP)
+ if ip == "" {
+ ip = reqctx.RealIP(ctx)
+ }
+ lang := strings.TrimSpace(device.Language)
+ if lang == "" {
+ lang = reqctx.Language(ctx)
+ }
+ return s.repo.UpsertDevice(ctx, &entity.Device{
+ UserID: userID,
+ DeviceID: device.DeviceID,
+ DeviceType: typ,
+ DeviceName: device.DeviceName,
+ AppVersion: device.AppVersion,
+ Language: lang,
+ LastIP: ip,
+ LastLoginAt: now,
+ CreatedAt: now,
+ })
+}
+
+func (s *Service) recordLogin(ctx context.Context, userID int64, device *entity.LoginDevice) error {
+ if device == nil {
+ return errs.ErrInvalidArgument
+ }
+ ip := strings.TrimSpace(device.RealIP)
+ if ip == "" {
+ ip = reqctx.RealIP(ctx)
+ }
+ lang := strings.TrimSpace(device.Language)
+ if lang == "" {
+ lang = reqctx.Language(ctx)
+ }
+ return s.repo.InsertLoginLog(ctx, &entity.LoginLog{
+ UserID: userID,
+ DeviceID: device.DeviceID,
+ DeviceType: entity.DeviceTypeOf(device),
+ DeviceName: device.DeviceName,
+ AppVersion: device.AppVersion,
+ Language: lang,
+ IP: ip,
+ CreatedAt: time.Now(),
+ })
+}
+
+func nicknameFromEmail(email string) string {
+ local, _, _ := strings.Cut(email, "@")
+ local = strings.TrimSpace(local)
+ if local == "" {
+ return "user"
+ }
+ if len(local) > 64 {
+ return local[:64]
+ }
+ return local
+}
diff --git a/internal/module/user/logic/login_test.go b/internal/module/user/logic/login_test.go
new file mode 100644
index 0000000..49bedd1
--- /dev/null
+++ b/internal/module/user/logic/login_test.go
@@ -0,0 +1,198 @@
+package logic
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "image/png"
+ "strconv"
+ "strings"
+ "testing"
+
+ "spark-server/internal/infrastructure/id"
+ "spark-server/internal/module/user/entity"
+ "spark-server/internal/module/user/model"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/reqctx"
+)
+
+func TestAuthenticateTestEmailAnyCode(t *testing.T) {
+ svc, repo, _ := testService(t)
+ ctx := reqctx.With(t.Context(), &reqctx.RequestContext{RealIP: "203.0.113.10", Language: "zh-CN", AppVersion: "1.0.0"})
+ device := &entity.LoginDevice{DeviceID: 7, Platform: "ios", DeviceName: "iPhone"}
+ u1, err := svc.AuthenticateEmail(ctx, "test01@spark.com", "whatever", device)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if u1.ID <= 0 || !entity.ValidSparkID(u1.SparkID) || u1.Nickname != "test01" || !entity.ValidQRCode(u1.QRCode) {
+ t.Fatalf("%+v", u1)
+ }
+ prefix := "perm/avatar/" + strconv.FormatInt(u1.ID, 10) + "/"
+ if !strings.HasPrefix(u1.Avatar, prefix) || !strings.HasSuffix(u1.Avatar, ".png") {
+ t.Fatalf("default avatar %s", u1.Avatar)
+ }
+ store, ok := svc.objects.(*memObjects)
+ if !ok {
+ t.Fatal("mem objects")
+ }
+ if _, err := png.Decode(bytes.NewReader(store.kv[u1.Avatar])); err != nil {
+ t.Fatalf("avatar png %v", err)
+ }
+ if strings.Contains(u1.QRCode, strconv.FormatInt(u1.ID, 10)) {
+ t.Fatalf("qr leaked uid %s", u1.QRCode)
+ }
+ got, err := svc.GetByQRCode(ctx, u1.QRCode)
+ if err != nil || got.ID != u1.ID {
+ t.Fatalf("lookup qr %v %+v", err, got)
+ }
+ if u1.LastLoginAt.IsZero() || u1.LastActiveAt.IsZero() || u1.Gender != entity.GenderUnknown {
+ t.Fatalf("profile times/gender %+v", u1)
+ }
+ u2, err := svc.AuthenticateEmail(ctx, "TEST01@spark.com", "0000", device)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if u2.ID != u1.ID {
+ t.Fatalf("same user %d %d", u1.ID, u2.ID)
+ }
+ dev, err := repo.GetDevice(ctx, u1.ID, 7)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if dev.DeviceType != "ios" || dev.LastIP != "203.0.113.10" || dev.AppVersion != "" {
+ t.Fatalf("%+v", dev)
+ }
+ device.AppVersion = "1.0.0"
+ device.RealIP = "203.0.113.10"
+ device.Language = "zh-CN"
+ if _, err := svc.AuthenticateEmail(ctx, "test01@spark.com", "x", device); err != nil {
+ t.Fatal(err)
+ }
+ dev, err = repo.GetDevice(ctx, u1.ID, 7)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if dev.AppVersion != "1.0.0" || dev.Language != "zh-CN" {
+ t.Fatalf("%+v", dev)
+ }
+ logs := repo.LoginLogs()
+ if len(logs) < 2 || logs[0].DeviceType != entity.DeviceIOS || logs[0].DeviceID != 7 {
+ t.Fatalf("logs %+v", logs)
+ }
+}
+
+func TestStaleUIDQRCodeIsReplaced(t *testing.T) {
+ svc, repo, _ := testService(t)
+ ctx := t.Context()
+ device := &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS}
+ u, err := svc.AuthenticateEmail(ctx, "test08@spark.com", "1", device)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := repo.UpdateQRCode(ctx, u.ID, "spark:u:"+strconv.FormatInt(u.ID, 10)); err != nil {
+ t.Fatal(err)
+ }
+ u2, err := svc.AuthenticateEmail(ctx, "test08@spark.com", "1", device)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !entity.ValidQRCode(u2.QRCode) || strings.Contains(u2.QRCode, "spark:") {
+ t.Fatalf("stale qr %s", u2.QRCode)
+ }
+}
+
+func TestAuthenticateNonTestNeedsCode(t *testing.T) {
+ svc, _, codes := testService(t)
+ ctx := t.Context()
+ device := &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceWeb}
+ _, err := svc.AuthenticateEmail(ctx, "ada@example.com", "123456", device)
+ if !errors.Is(err, errs.ErrUnauthorized) {
+ t.Fatalf("no code: %v", err)
+ }
+ if err := codes.Put(ctx, "ada@example.com", "123456", 60); err != nil {
+ t.Fatal(err)
+ }
+ u, err := svc.AuthenticateEmail(ctx, "ada@example.com", "123456", device)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if u.ID <= 0 {
+ t.Fatal(u)
+ }
+ _, err = svc.AuthenticateEmail(ctx, "ada@example.com", "123456", device)
+ if !errors.Is(err, errs.ErrUnauthorized) {
+ t.Fatalf("consumed: %v", err)
+ }
+}
+
+func TestSnowflakeIDsIncrease(t *testing.T) {
+ svc, _, _ := testService(t)
+ ctx := t.Context()
+ a, err := svc.AuthenticateEmail(ctx, "test02@spark.com", "1", &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS})
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := svc.AuthenticateEmail(ctx, "test03@spark.com", "1", &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if b.ID <= a.ID {
+ t.Fatalf("ids %d %d", a.ID, b.ID)
+ }
+}
+
+func TestRegisterFallsBackToDataURIWithoutObjectStore(t *testing.T) {
+ ids, err := id.New(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ svc := NewService(model.NewMemoryRepo(), model.NewMemoryCodes(), ids, nil)
+ device := &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS}
+ u, err := svc.AuthenticateEmail(t.Context(), "test09@spark.com", "1", device)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(u.Avatar, "data:image/svg+xml;base64,") {
+ t.Fatalf("fallback avatar %s", u.Avatar)
+ }
+}
+
+func TestRegisterFallsBackWhenObjectPutFails(t *testing.T) {
+ ids, err := id.New(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ svc := NewService(model.NewMemoryRepo(), model.NewMemoryCodes(), ids, failObjects{})
+ device := &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS}
+ u, err := svc.AuthenticateEmail(t.Context(), "test09@spark.com", "1", device)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(u.Avatar, "data:image/svg+xml;base64,") {
+ t.Fatalf("fallback avatar %s", u.Avatar)
+ }
+}
+
+func TestEmptyAvatarBackfilledOnLogin(t *testing.T) {
+ svc, _, _ := testService(t)
+ ctx := t.Context()
+ device := &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS}
+ u, err := svc.AuthenticateEmail(ctx, "test10@spark.com", "1", device)
+ if err != nil {
+ t.Fatal(err)
+ }
+ empty := ""
+ if _, err := svc.UpdateProfile(ctx, u.ID, UpdateProfileInput{Avatar: &empty}); err != nil {
+ t.Fatal(err)
+ }
+ u2, err := svc.AuthenticateEmail(ctx, "test10@spark.com", "1", device)
+ if err != nil || strings.TrimSpace(u2.Avatar) == "" {
+ t.Fatalf("backfill %+v %v", u2, err)
+ }
+}
+
+type failObjects struct{}
+
+func (failObjects) Put(context.Context, string, []byte, string) error {
+ return errors.New("oss down")
+}
diff --git a/internal/module/user/logic/profile.go b/internal/module/user/logic/profile.go
new file mode 100644
index 0000000..9d8a923
--- /dev/null
+++ b/internal/module/user/logic/profile.go
@@ -0,0 +1,181 @@
+package logic
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "spark-server/internal/module/user/entity"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
+ "spark-server/internal/pkg/reqctx"
+ "spark-server/internal/pkg/validate"
+)
+
+// Profile is the public account view.
+type Profile struct {
+ UID int64 `json:"uid"`
+ SparkID string `json:"spark_id"`
+ Nickname string `json:"nickname"`
+ Avatar string `json:"avatar"`
+ QRCode string `json:"qrcode"`
+ Birthday string `json:"birthday"`
+ Gender int `json:"gender"`
+ Status int `json:"status"`
+ LastLoginTime int64 `json:"last_login_time"`
+ LastActiveTime int64 `json:"last_active_time"`
+ CreateTime int64 `json:"create_time"`
+ UpdateTime int64 `json:"update_time"`
+}
+
+func PublicProfile(u *entity.User) *Profile {
+ if u == nil {
+ return nil
+ }
+ birthday := ""
+ if !u.Birthday.IsZero() {
+ birthday = u.Birthday.Format("2006-01-02")
+ }
+ return &Profile{
+ UID: u.ID,
+ SparkID: u.SparkID,
+ Nickname: u.Nickname,
+ Avatar: u.Avatar,
+ QRCode: u.QRCode,
+ Birthday: birthday,
+ Gender: u.Gender,
+ Status: u.Status,
+ LastLoginTime: unixMS(u.LastLoginAt),
+ LastActiveTime: unixMS(u.LastActiveAt),
+ CreateTime: unixMS(u.CreatedAt),
+ UpdateTime: unixMS(u.UpdatedAt),
+ }
+}
+
+func unixMS(t time.Time) int64 {
+ if t.IsZero() {
+ return 0
+ }
+ return t.UnixMilli()
+}
+
+func (s *Service) Get(ctx context.Context, id int64) (*entity.User, error) {
+ if s == nil || s.repo == nil || id <= 0 {
+ return nil, errs.ErrNotFound
+ }
+ return s.repo.GetByID(ctx, id)
+}
+
+func (s *Service) GetByQRCode(ctx context.Context, qrcode string) (*entity.User, error) {
+ if s == nil || s.repo == nil || !entity.ValidQRCode(qrcode) {
+ return nil, errs.ErrNotFound
+ }
+ return s.repo.GetByQRCode(ctx, qrcode)
+}
+
+type UpdateSparkIDInput struct {
+ SparkID string `json:"spark_id" validate:"required,min=6,max=32" label:"field.spark_id" errmsg:"required:validate.user.spark_id_required;min:validate.user.spark_id_invalid;max:validate.user.spark_id_invalid"`
+}
+
+// UpdateProfileInput is a partial profile PATCH. Nil fields are left unchanged.
+type UpdateProfileInput struct {
+ SparkID *string `json:"spark_id" validate:"omitempty,min=6,max=32" label:"field.spark_id" errmsg:"min:validate.user.spark_id_invalid;max:validate.user.spark_id_invalid"`
+ Nickname *string `json:"nickname" validate:"omitempty,min=1,max=64" label:"field.nickname" errmsg:"min:validate.user.nickname_invalid;max:validate.user.nickname_invalid"`
+ Avatar *string `json:"avatar" validate:"omitempty,max=512" label:"field.avatar" errmsg:"max:validate.user.avatar_invalid"`
+ Gender *int `json:"gender" validate:"omitempty,oneof=0 1 2" label:"field.gender" errmsg:"oneof:validate.user.gender_invalid"`
+ Birthday *string `json:"birthday" validate:"omitempty" label:"field.birthday"`
+}
+
+func (s *Service) UpdateSparkID(ctx context.Context, userID int64, sparkID string) (*entity.User, error) {
+ id := sparkID
+ return s.UpdateProfile(ctx, userID, UpdateProfileInput{SparkID: &id})
+}
+
+func (s *Service) UpdateProfile(ctx context.Context, userID int64, in UpdateProfileInput) (*entity.User, error) {
+ if s == nil || s.repo == nil || userID <= 0 {
+ return nil, errs.ErrUnauthorized
+ }
+ if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
+ return nil, err
+ }
+ u, err := s.repo.GetByID(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+ if !profilePatchSet(&in) {
+ return u, nil
+ }
+ if err := applyProfilePatch(ctx, u, &in); err != nil {
+ return nil, err
+ }
+ u.UpdatedAt = time.Now()
+ if err := s.repo.UpdateProfile(ctx, u); err != nil {
+ return nil, err
+ }
+ return u, nil
+}
+
+func profilePatchSet(in *UpdateProfileInput) bool {
+ if in == nil {
+ return false
+ }
+ return in.SparkID != nil || in.Nickname != nil || in.Avatar != nil || in.Gender != nil || in.Birthday != nil
+}
+
+func applyProfilePatch(ctx context.Context, u *entity.User, in *UpdateProfileInput) error {
+ if in == nil {
+ return nil
+ }
+ lang := reqctx.Lang(ctx)
+ if in.SparkID != nil {
+ sparkID := strings.TrimSpace(*in.SparkID)
+ if !entity.ValidSparkID(sparkID) {
+ return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyUserSparkIDInvalid, nil))
+ }
+ u.SparkID = sparkID
+ }
+ if in.Nickname != nil {
+ nick := strings.TrimSpace(*in.Nickname)
+ if nick == "" || len(nick) > 64 {
+ return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyUserNicknameInvalid, nil))
+ }
+ u.Nickname = nick
+ }
+ if in.Avatar != nil {
+ avatar := strings.TrimSpace(*in.Avatar)
+ if len(avatar) > 512 {
+ return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyUserAvatarInvalid, nil))
+ }
+ u.Avatar = avatar
+ }
+ if in.Gender != nil {
+ if !entity.ValidGender(*in.Gender) {
+ return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyUserGenderInvalid, nil))
+ }
+ u.Gender = *in.Gender
+ }
+ if in.Birthday != nil {
+ day, err := parseBirthday(lang, *in.Birthday)
+ if err != nil {
+ return err
+ }
+ u.Birthday = day
+ }
+ return nil
+}
+
+func parseBirthday(lang i18n.Lang, raw string) (time.Time, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return time.Time{}, nil
+ }
+ day, err := time.ParseInLocation("2006-01-02", raw, time.Local)
+ if err != nil {
+ return time.Time{}, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyUserBirthdayInvalid, nil))
+ }
+ today := time.Now().In(time.Local)
+ if day.After(today) || day.Year() < 1900 {
+ return time.Time{}, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyUserBirthdayInvalid, nil))
+ }
+ return day, nil
+}
diff --git a/internal/module/user/logic/profile_test.go b/internal/module/user/logic/profile_test.go
new file mode 100644
index 0000000..037dbdd
--- /dev/null
+++ b/internal/module/user/logic/profile_test.go
@@ -0,0 +1,74 @@
+package logic
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "spark-server/internal/module/user/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+func TestUpdateSparkID(t *testing.T) {
+ svc, _, _ := testService(t)
+ ctx := t.Context()
+ u, err := svc.AuthenticateEmail(ctx, "test04@spark.com", "1", &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS})
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := svc.UpdateSparkID(ctx, u.ID, "alice_ok")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.SparkID != "alice_ok" {
+ t.Fatalf("%+v", got)
+ }
+ p := PublicProfile(got)
+ if p.UID != u.ID || p.SparkID != "alice_ok" || p.CreateTime == 0 || p.UpdateTime == 0 {
+ t.Fatalf("profile %+v", p)
+ }
+ other, err := svc.AuthenticateEmail(ctx, "test05@spark.com", "1", &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS})
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = svc.UpdateSparkID(ctx, other.ID, "alice_ok")
+ if !errors.Is(err, errs.ErrDuplicate) {
+ t.Fatalf("dup %v", err)
+ }
+ _, err = svc.UpdateSparkID(ctx, u.ID, "1bad")
+ app, ok := errs.AsAppError(err)
+ if !ok || app.Code != errs.ErrInvalidArgument.Code || !strings.Contains(app.Msg, "字母开头") {
+ t.Fatalf("invalid %v", err)
+ }
+}
+
+func TestUpdateProfileFields(t *testing.T) {
+ svc, _, _ := testService(t)
+ ctx := t.Context()
+ u, err := svc.AuthenticateEmail(ctx, "test06@spark.com", "1", &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceWeb})
+ if err != nil {
+ t.Fatal(err)
+ }
+ nick := "Ada"
+ avatar := "oss://avatar/ada"
+ gender := entity.GenderFemale
+ day := "1994-05-01"
+ got, err := svc.UpdateProfile(ctx, u.ID, UpdateProfileInput{
+ Nickname: &nick, Avatar: &avatar, Gender: &gender, Birthday: &day,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ p := PublicProfile(got)
+ if p.Nickname != "Ada" || p.Avatar != "oss://avatar/ada" || p.Gender != entity.GenderFemale || p.Birthday != "1994-05-01" {
+ t.Fatalf("profile %+v", p)
+ }
+ empty := ""
+ got, err = svc.UpdateProfile(ctx, u.ID, UpdateProfileInput{Birthday: &empty})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got.Birthday.IsZero() {
+ t.Fatalf("birthday %v", got.Birthday)
+ }
+}
diff --git a/internal/module/user/logic/service.go b/internal/module/user/logic/service.go
new file mode 100644
index 0000000..208076e
--- /dev/null
+++ b/internal/module/user/logic/service.go
@@ -0,0 +1,47 @@
+// Package logic is the user application use-case layer, split by function (profile, login, email code).
+package logic
+
+import (
+ "context"
+ "time"
+
+ "spark-server/internal/module/user/entity"
+)
+
+// Repository is user persistence. Model implements it; do not split this by table.
+type Repository interface {
+ GetByID(ctx context.Context, id int64) (*entity.User, error)
+ GetByEmail(ctx context.Context, email string) (*entity.User, error)
+ GetBySparkID(ctx context.Context, sparkID string) (*entity.User, error)
+ GetByQRCode(ctx context.Context, qrcode string) (*entity.User, error)
+ CreateWithEmail(ctx context.Context, u *entity.User, email string) error
+ UpdateSparkID(ctx context.Context, userID int64, sparkID string) error
+ UpdateQRCode(ctx context.Context, userID int64, qrcode string) error
+ UpdateProfile(ctx context.Context, u *entity.User) error
+ TouchLogin(ctx context.Context, userID int64, at time.Time) error
+ UpsertDevice(ctx context.Context, d *entity.Device) error
+ GetDevice(ctx context.Context, userID int64, deviceID uint64) (*entity.Device, error)
+ InsertLoginLog(ctx context.Context, log *entity.LoginLog) error
+}
+
+// IDGen issues time-ordered Snowflake IDs (user PK).
+type IDGen interface {
+ NextID() (int64, error)
+}
+
+// Objects stores the generated default avatar. Nil or Put error keeps an SVG data-URI fallback.
+type Objects interface {
+ Put(ctx context.Context, key string, body []byte, contentType string) error
+}
+
+// Service is the user application use-case (HTTP and TCP share it).
+type Service struct {
+ repo Repository
+ codes CodeStore
+ ids IDGen
+ objects Objects
+}
+
+func NewService(repo Repository, codes CodeStore, ids IDGen, objects Objects) *Service {
+ return &Service{repo: repo, codes: codes, ids: ids, objects: objects}
+}
diff --git a/internal/module/user/logic/service_test.go b/internal/module/user/logic/service_test.go
new file mode 100644
index 0000000..7b70756
--- /dev/null
+++ b/internal/module/user/logic/service_test.go
@@ -0,0 +1,36 @@
+package logic
+
+import (
+ "context"
+ "testing"
+
+ "spark-server/internal/infrastructure/id"
+ "spark-server/internal/module/user/model"
+)
+
+func testService(t *testing.T) (*Service, *model.MemoryRepo, *model.MemoryCodes) {
+ t.Helper()
+ ids, err := id.New(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ repo := model.NewMemoryRepo()
+ codes := model.NewMemoryCodes()
+ return NewService(repo, codes, ids, newMemObjects()), repo, codes
+}
+
+type memObjects struct {
+ kv map[string][]byte
+}
+
+func newMemObjects() *memObjects {
+ return &memObjects{kv: map[string][]byte{}}
+}
+
+func (m *memObjects) Put(_ context.Context, key string, body []byte, _ string) error {
+ if m.kv == nil {
+ m.kv = map[string][]byte{}
+ }
+ m.kv[key] = append([]byte(nil), body...)
+ return nil
+}
diff --git a/internal/module/user/model/email_code.go b/internal/module/user/model/email_code.go
new file mode 100644
index 0000000..d54354a
--- /dev/null
+++ b/internal/module/user/model/email_code.go
@@ -0,0 +1,68 @@
+package model
+
+import (
+ "context"
+ "time"
+
+ "github.com/redis/go-redis/v9"
+
+ "spark-server/internal/pkg/errs"
+)
+
+const (
+ emailCodeKeyPrefix = "auth:email-code:"
+ emailCodeCDPrefix = "auth:email-cd:"
+)
+
+type RedisCodes struct {
+ rdb *redis.Client
+}
+
+func NewRedisCodes(rdb *redis.Client) *RedisCodes {
+ return &RedisCodes{rdb: rdb}
+}
+
+func (r *RedisCodes) Put(ctx context.Context, email, code string, ttlSeconds int) error {
+ if r == nil || r.rdb == nil {
+ return errs.ErrAvailable
+ }
+ if err := r.rdb.Set(ctx, emailCodeKeyPrefix+email, code, time.Duration(ttlSeconds)*time.Second).Err(); err != nil {
+ return errs.ErrAvailable.WithError("email-code", err)
+ }
+ return nil
+}
+
+func (r *RedisCodes) Get(ctx context.Context, email string) (string, error) {
+ if r == nil || r.rdb == nil {
+ return "", errs.ErrAvailable
+ }
+ v, err := r.rdb.Get(ctx, emailCodeKeyPrefix+email).Result()
+ if err == redis.Nil {
+ return "", nil
+ }
+ if err != nil {
+ return "", errs.ErrAvailable.WithError("email-code", err)
+ }
+ return v, nil
+}
+
+func (r *RedisCodes) Del(ctx context.Context, email string) error {
+ if r == nil || r.rdb == nil {
+ return errs.ErrAvailable
+ }
+ if err := r.rdb.Del(ctx, emailCodeKeyPrefix+email).Err(); err != nil {
+ return errs.ErrAvailable.WithError("email-code", err)
+ }
+ return nil
+}
+
+func (r *RedisCodes) SetCooldown(ctx context.Context, email string, ttlSeconds int) (bool, error) {
+ if r == nil || r.rdb == nil {
+ return false, errs.ErrAvailable
+ }
+ ok, err := r.rdb.SetNX(ctx, emailCodeCDPrefix+email, "1", time.Duration(ttlSeconds)*time.Second).Result()
+ if err != nil {
+ return false, errs.ErrAvailable.WithError("email-code", err)
+ }
+ return ok, nil
+}
diff --git a/internal/module/user/model/email_code_memory.go b/internal/module/user/model/email_code_memory.go
new file mode 100644
index 0000000..a1921cc
--- /dev/null
+++ b/internal/module/user/model/email_code_memory.go
@@ -0,0 +1,57 @@
+package model
+
+import (
+ "context"
+ "sync"
+ "time"
+)
+
+type MemoryCodes struct {
+ mu sync.Mutex
+ codes map[string]codeEntry
+ cooldowns map[string]time.Time
+}
+
+type codeEntry struct {
+ code string
+ exp time.Time
+}
+
+func NewMemoryCodes() *MemoryCodes {
+ return &MemoryCodes{codes: make(map[string]codeEntry), cooldowns: make(map[string]time.Time)}
+}
+
+func (m *MemoryCodes) Put(_ context.Context, email, code string, ttlSeconds int) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.codes[email] = codeEntry{code: code, exp: time.Now().Add(time.Duration(ttlSeconds) * time.Second)}
+ return nil
+}
+
+func (m *MemoryCodes) Get(_ context.Context, email string) (string, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ e, ok := m.codes[email]
+ if !ok || time.Now().After(e.exp) {
+ delete(m.codes, email)
+ return "", nil
+ }
+ return e.code, nil
+}
+
+func (m *MemoryCodes) Del(_ context.Context, email string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ delete(m.codes, email)
+ return nil
+}
+
+func (m *MemoryCodes) SetCooldown(_ context.Context, email string, ttlSeconds int) (bool, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if exp, ok := m.cooldowns[email]; ok && time.Now().Before(exp) {
+ return false, nil
+ }
+ m.cooldowns[email] = time.Now().Add(time.Duration(ttlSeconds) * time.Second)
+ return true, nil
+}
diff --git a/internal/module/user/model/memory.go b/internal/module/user/model/memory.go
new file mode 100644
index 0000000..d758641
--- /dev/null
+++ b/internal/module/user/model/memory.go
@@ -0,0 +1,219 @@
+package model
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "spark-server/internal/module/user/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+type MemoryRepo struct {
+ mu sync.Mutex
+ byID map[int64]*entity.User
+ byEmail map[string]int64
+ bySpark map[string]int64
+ devices map[deviceKey]*entity.Device
+ logs []*entity.LoginLog
+ deviceSeq int64
+ logSeq int64
+}
+
+type deviceKey struct {
+ UserID int64
+ DeviceID uint64
+}
+
+func NewMemoryRepo() *MemoryRepo {
+ return &MemoryRepo{
+ byID: make(map[int64]*entity.User),
+ byEmail: make(map[string]int64),
+ bySpark: make(map[string]int64),
+ devices: make(map[deviceKey]*entity.Device),
+ logs: make([]*entity.LoginLog, 0),
+ }
+}
+
+func (m *MemoryRepo) GetByID(_ context.Context, id int64) (*entity.User, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ u, ok := m.byID[id]
+ if !ok {
+ return nil, errs.ErrNotFound
+ }
+ cp := *u
+ return &cp, nil
+}
+
+func (m *MemoryRepo) GetByEmail(_ context.Context, email string) (*entity.User, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ id, ok := m.byEmail[email]
+ if !ok {
+ return nil, errs.ErrNotFound
+ }
+ cp := *m.byID[id]
+ return &cp, nil
+}
+
+func (m *MemoryRepo) GetBySparkID(_ context.Context, sparkID string) (*entity.User, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ id, ok := m.bySpark[sparkID]
+ if !ok {
+ return nil, errs.ErrNotFound
+ }
+ cp := *m.byID[id]
+ return &cp, nil
+}
+
+func (m *MemoryRepo) GetByQRCode(_ context.Context, qrcode string) (*entity.User, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if qrcode == "" {
+ return nil, errs.ErrNotFound
+ }
+ for _, u := range m.byID {
+ if u.QRCode == qrcode {
+ cp := *u
+ return &cp, nil
+ }
+ }
+ return nil, errs.ErrNotFound
+}
+
+func (m *MemoryRepo) CreateWithEmail(_ context.Context, u *entity.User, email string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if _, ok := m.byEmail[email]; ok {
+ return errs.ErrDuplicate
+ }
+ if _, ok := m.bySpark[u.SparkID]; ok {
+ return errs.ErrDuplicate
+ }
+ if _, ok := m.byID[u.ID]; ok {
+ return errs.ErrDuplicate
+ }
+ cp := *u
+ m.byID[u.ID] = &cp
+ m.byEmail[email] = u.ID
+ m.bySpark[u.SparkID] = u.ID
+ return nil
+}
+
+func (m *MemoryRepo) UpdateSparkID(_ context.Context, userID int64, sparkID string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ u, ok := m.byID[userID]
+ if !ok {
+ return errs.ErrNotFound
+ }
+ if other, ok := m.bySpark[sparkID]; ok && other != userID {
+ return errs.ErrDuplicate
+ }
+ delete(m.bySpark, u.SparkID)
+ u.SparkID = sparkID
+ u.UpdatedAt = time.Now()
+ m.bySpark[sparkID] = userID
+ return nil
+}
+
+func (m *MemoryRepo) TouchLogin(_ context.Context, userID int64, at time.Time) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ u, ok := m.byID[userID]
+ if !ok {
+ return errs.ErrNotFound
+ }
+ u.LastLoginAt = at
+ u.LastActiveAt = at
+ return nil
+}
+
+func (m *MemoryRepo) UpsertDevice(_ context.Context, d *entity.Device) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ key := deviceKey{UserID: d.UserID, DeviceID: d.DeviceID}
+ if old, ok := m.devices[key]; ok {
+ d.ID = old.ID
+ d.CreatedAt = old.CreatedAt
+ } else if d.ID == 0 {
+ m.deviceSeq++
+ d.ID = m.deviceSeq
+ }
+ cp := *d
+ m.devices[key] = &cp
+ return nil
+}
+
+func (m *MemoryRepo) GetDevice(_ context.Context, userID int64, deviceID uint64) (*entity.Device, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ d, ok := m.devices[deviceKey{UserID: userID, DeviceID: deviceID}]
+ if !ok {
+ return nil, errs.ErrNotFound
+ }
+ cp := *d
+ return &cp, nil
+}
+
+func (m *MemoryRepo) UpdateQRCode(_ context.Context, userID int64, qrcode string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ u, ok := m.byID[userID]
+ if !ok {
+ return errs.ErrNotFound
+ }
+ u.QRCode = qrcode
+ return nil
+}
+
+func (m *MemoryRepo) UpdateProfile(_ context.Context, u *entity.User) error {
+ if u == nil || u.ID <= 0 {
+ return errs.ErrInvalidArgument
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ cur, ok := m.byID[u.ID]
+ if !ok {
+ return errs.ErrNotFound
+ }
+ if other, ok := m.bySpark[u.SparkID]; ok && other != u.ID {
+ return errs.ErrDuplicate
+ }
+ delete(m.bySpark, cur.SparkID)
+ cur.SparkID = u.SparkID
+ cur.Nickname = u.Nickname
+ cur.Avatar = u.Avatar
+ cur.Gender = u.Gender
+ cur.Birthday = u.Birthday
+ cur.UpdatedAt = u.UpdatedAt
+ m.bySpark[cur.SparkID] = u.ID
+ return nil
+}
+
+func (m *MemoryRepo) InsertLoginLog(_ context.Context, log *entity.LoginLog) error {
+ if log == nil {
+ return nil
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.logSeq++
+ cp := *log
+ cp.ID = m.logSeq
+ log.ID = cp.ID
+ m.logs = append(m.logs, &cp)
+ return nil
+}
+
+func (m *MemoryRepo) LoginLogs() []*entity.LoginLog {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ out := make([]*entity.LoginLog, len(m.logs))
+ for i, row := range m.logs {
+ cp := *row
+ out[i] = &cp
+ }
+ return out
+}
diff --git a/internal/module/user/model/repo.go b/internal/module/user/model/repo.go
new file mode 100644
index 0000000..0e2a69a
--- /dev/null
+++ b/internal/module/user/model/repo.go
@@ -0,0 +1,42 @@
+// Package model is the user persistence layer. One file per table.
+package model
+
+import (
+ "context"
+ "errors"
+
+ mysqldriver "github.com/go-sql-driver/mysql"
+ "gorm.io/gorm"
+
+ "spark-server/internal/pkg/errs"
+)
+
+// Repo is the MySQL implementation of user persistence.
+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("user-mysql", err)
+}
diff --git a/internal/module/user/model/user.go b/internal/module/user/model/user.go
new file mode 100644
index 0000000..b5d7f7e
--- /dev/null
+++ b/internal/module/user/model/user.go
@@ -0,0 +1,191 @@
+package model
+
+import (
+ "context"
+ "time"
+
+ "spark-server/internal/module/user/entity"
+ "spark-server/internal/pkg/errs"
+)
+
+// User maps table users: account row, Snowflake uid, mutable spark_id.
+type User struct {
+ ID int64 `gorm:"column:id;primaryKey;comment:Snowflake 用户 id(uid),时间有序,非自增"`
+ SparkID string `gorm:"column:spark_id;comment:对外展示的账号,用户可改,全局唯一"`
+ Nickname string `gorm:"column:nickname;comment:昵称"`
+ Avatar string `gorm:"column:avatar;comment:头像对象存储 key 或 URL,不存图片二进制"`
+ QRCode string `gorm:"column:qrcode;comment:个人二维码扫描票据,随机生成,不含 uid 或 spark_id"`
+ Birthday *time.Time `gorm:"column:birthday;type:date;comment:生日,未填为空"`
+ Gender int `gorm:"column:gender;comment:0=未知 1=男 2=女"`
+ Status int `gorm:"column:status;comment:1=正常 0=禁用"`
+ LastLoginAt *time.Time `gorm:"column:last_login_at;comment:最近一次登录时间"`
+ LastActiveAt *time.Time `gorm:"column:last_active_at;comment:最近一次活跃时间(登录、心跳、发消息等)"`
+ CreatedAt time.Time `gorm:"column:created_at;comment:注册时间(create_time)"`
+ UpdatedAt time.Time `gorm:"column:updated_at;comment:资料最近更新时间(update_time)"`
+}
+
+func (User) TableName() string { return "users" }
+
+func (r *User) entity() *entity.User {
+ if r == nil {
+ return nil
+ }
+ return &entity.User{
+ ID: r.ID,
+ SparkID: r.SparkID,
+ Nickname: r.Nickname,
+ Avatar: r.Avatar,
+ QRCode: r.QRCode,
+ Birthday: timeOrZero(r.Birthday),
+ Gender: r.Gender,
+ Status: r.Status,
+ LastLoginAt: timeOrZero(r.LastLoginAt),
+ LastActiveAt: timeOrZero(r.LastActiveAt),
+ CreatedAt: r.CreatedAt,
+ UpdatedAt: r.UpdatedAt,
+ }
+}
+
+func fromEntity(u *entity.User) User {
+ row := User{
+ ID: u.ID,
+ SparkID: u.SparkID,
+ Nickname: u.Nickname,
+ Avatar: u.Avatar,
+ QRCode: u.QRCode,
+ Gender: u.Gender,
+ Status: u.Status,
+ CreatedAt: u.CreatedAt,
+ UpdatedAt: u.UpdatedAt,
+ }
+ row.Birthday = timePtr(u.Birthday)
+ row.LastLoginAt = timePtr(u.LastLoginAt)
+ row.LastActiveAt = timePtr(u.LastActiveAt)
+ return row
+}
+
+func timeOrZero(t *time.Time) time.Time {
+ if t == nil {
+ return time.Time{}
+ }
+ return *t
+}
+
+func timePtr(t time.Time) *time.Time {
+ if t.IsZero() {
+ return nil
+ }
+ return &t
+}
+
+func (m *Repo) GetByID(ctx context.Context, id int64) (*entity.User, error) {
+ db, err := m.session(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var row User
+ if err := db.Where("id = ?", id).Take(&row).Error; err != nil {
+ return nil, mapDBErr(err)
+ }
+ return row.entity(), nil
+}
+
+func (m *Repo) GetBySparkID(ctx context.Context, sparkID string) (*entity.User, error) {
+ db, err := m.session(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var row User
+ if err := db.Where("spark_id = ?", sparkID).Take(&row).Error; err != nil {
+ return nil, mapDBErr(err)
+ }
+ return row.entity(), nil
+}
+
+func (m *Repo) GetByQRCode(ctx context.Context, qrcode string) (*entity.User, error) {
+ db, err := m.session(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var row User
+ if err := db.Where("qrcode = ?", qrcode).Take(&row).Error; err != nil {
+ return nil, mapDBErr(err)
+ }
+ return row.entity(), nil
+}
+
+func (m *Repo) UpdateSparkID(ctx context.Context, userID int64, sparkID string) error {
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ res := db.Model(&User{}).Where("id = ?", userID).Updates(map[string]any{
+ "spark_id": sparkID,
+ "updated_at": time.Now(),
+ })
+ if res.Error != nil {
+ return mapDBErr(res.Error)
+ }
+ if res.RowsAffected == 0 {
+ return errs.ErrNotFound
+ }
+ return nil
+}
+
+func (m *Repo) UpdateQRCode(ctx context.Context, userID int64, qrcode string) error {
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ res := db.Model(&User{}).Where("id = ?", userID).Update("qrcode", qrcode)
+ if res.Error != nil {
+ return mapDBErr(res.Error)
+ }
+ if res.RowsAffected == 0 {
+ return errs.ErrNotFound
+ }
+ return nil
+}
+
+func (m *Repo) UpdateProfile(ctx context.Context, u *entity.User) error {
+ if u == nil || u.ID <= 0 {
+ return errs.ErrInvalidArgument
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ res := db.Model(&User{}).Where("id = ?", u.ID).Select("spark_id", "nickname", "avatar", "gender", "birthday", "updated_at").Updates(map[string]any{
+ "spark_id": u.SparkID,
+ "nickname": u.Nickname,
+ "avatar": u.Avatar,
+ "gender": u.Gender,
+ "birthday": timePtr(u.Birthday),
+ "updated_at": u.UpdatedAt,
+ })
+ if res.Error != nil {
+ return mapDBErr(res.Error)
+ }
+ if res.RowsAffected == 0 {
+ return errs.ErrNotFound
+ }
+ return nil
+}
+
+func (m *Repo) TouchLogin(ctx context.Context, userID int64, at time.Time) error {
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ res := db.Model(&User{}).Where("id = ?", userID).Updates(map[string]any{
+ "last_login_at": at,
+ "last_active_at": at,
+ })
+ if res.Error != nil {
+ return mapDBErr(res.Error)
+ }
+ if res.RowsAffected == 0 {
+ return errs.ErrNotFound
+ }
+ return nil
+}
diff --git a/internal/module/user/model/user_device.go b/internal/module/user/model/user_device.go
new file mode 100644
index 0000000..2ff9225
--- /dev/null
+++ b/internal/module/user/model/user_device.go
@@ -0,0 +1,81 @@
+package model
+
+import (
+ "context"
+ "time"
+
+ "gorm.io/gorm/clause"
+
+ "spark-server/internal/module/user/entity"
+)
+
+// UserDevice maps table user_devices: devices that have logged in.
+type UserDevice struct {
+ ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增,仅内部用;业务唯一键是 (user_id, device_id)"`
+ UserID int64 `gorm:"column:user_id;comment:所属用户"`
+ DeviceID uint64 `gorm:"column:device_id;comment:客户端设备 id,写入 JWT did"`
+ DeviceType string `gorm:"column:platform;comment:设备类型:ios/android/harmony/windows/macos/web"`
+ DeviceName string `gorm:"column:device_name;comment:设备展示名"`
+ AppVersion string `gorm:"column:app_version;comment:最近一次登录的客户端版本"`
+ Language string `gorm:"column:language;comment:最近一次登录语言,如 zh-CN"`
+ LastIP string `gorm:"column:last_ip;comment:最近一次登录的 PROXY RealIP,禁止用环回地址"`
+ LastLoginAt time.Time `gorm:"column:last_login_at;comment:最近一次登录时间"`
+ CreatedAt time.Time `gorm:"column:created_at;comment:该设备首次登录时间"`
+}
+
+func (UserDevice) TableName() string { return "user_devices" }
+
+func (row *UserDevice) entity() *entity.Device {
+ if row == nil {
+ return nil
+ }
+ return &entity.Device{
+ ID: row.ID,
+ UserID: row.UserID,
+ DeviceID: row.DeviceID,
+ DeviceType: row.DeviceType,
+ DeviceName: row.DeviceName,
+ AppVersion: row.AppVersion,
+ Language: row.Language,
+ LastIP: row.LastIP,
+ LastLoginAt: row.LastLoginAt,
+ CreatedAt: row.CreatedAt,
+ }
+}
+
+func (m *Repo) UpsertDevice(ctx context.Context, d *entity.Device) error {
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ row := UserDevice{
+ UserID: d.UserID,
+ DeviceID: d.DeviceID,
+ DeviceType: d.DeviceType,
+ DeviceName: d.DeviceName,
+ AppVersion: d.AppVersion,
+ Language: d.Language,
+ LastIP: d.LastIP,
+ LastLoginAt: d.LastLoginAt,
+ CreatedAt: d.CreatedAt,
+ }
+ err = db.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "user_id"}, {Name: "device_id"}},
+ DoUpdates: clause.AssignmentColumns([]string{
+ "platform", "device_name", "app_version", "language", "last_ip", "last_login_at",
+ }),
+ }).Create(&row).Error
+ return mapDBErr(err)
+}
+
+func (m *Repo) GetDevice(ctx context.Context, userID int64, deviceID uint64) (*entity.Device, error) {
+ db, err := m.session(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var row UserDevice
+ if err := db.Where("user_id = ? AND device_id = ?", userID, deviceID).Take(&row).Error; err != nil {
+ return nil, mapDBErr(err)
+ }
+ return row.entity(), nil
+}
diff --git a/internal/module/user/model/user_email.go b/internal/module/user/model/user_email.go
new file mode 100644
index 0000000..519217a
--- /dev/null
+++ b/internal/module/user/model/user_email.go
@@ -0,0 +1,49 @@
+package model
+
+import (
+ "context"
+ "time"
+
+ "gorm.io/gorm"
+
+ "spark-server/internal/module/user/entity"
+)
+
+// UserEmail maps table user_emails: one normalized email to one user.
+type UserEmail struct {
+ Email string `gorm:"column:email;primaryKey;comment:规范化后的小写邮箱,登录账号"`
+ UserID int64 `gorm:"column:user_id;comment:绑定的 users.id,一对一"`
+ CreatedAt time.Time `gorm:"column:created_at;comment:绑定时间"`
+}
+
+func (UserEmail) TableName() string { return "user_emails" }
+
+func (m *Repo) GetByEmail(ctx context.Context, email string) (*entity.User, error) {
+ db, err := m.session(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var bind UserEmail
+ if err := db.Where("email = ?", email).Take(&bind).Error; err != nil {
+ return nil, mapDBErr(err)
+ }
+ return m.GetByID(ctx, bind.UserID)
+}
+
+func (m *Repo) CreateWithEmail(ctx context.Context, u *entity.User, email string) error {
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ return db.Transaction(func(tx *gorm.DB) error {
+ row := fromEntity(u)
+ if err := tx.Create(&row).Error; err != nil {
+ return mapDBErr(err)
+ }
+ bind := UserEmail{Email: email, UserID: u.ID, CreatedAt: u.CreatedAt}
+ if err := tx.Create(&bind).Error; err != nil {
+ return mapDBErr(err)
+ }
+ return nil
+ })
+}
diff --git a/internal/module/user/model/user_login_log.go b/internal/module/user/model/user_login_log.go
new file mode 100644
index 0000000..6932f62
--- /dev/null
+++ b/internal/module/user/model/user_login_log.go
@@ -0,0 +1,48 @@
+package model
+
+import (
+ "context"
+ "time"
+
+ "spark-server/internal/module/user/entity"
+)
+
+// UserLoginLog maps table user_login_logs: one row per successful login.
+type UserLoginLog struct {
+ ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:行 id,表内自增"`
+ UserID int64 `gorm:"column:user_id;comment:登录用户"`
+ DeviceID uint64 `gorm:"column:device_id;comment:客户端设备 id"`
+ DeviceType string `gorm:"column:device_type;comment:设备类型:ios/android/harmony/windows/macos/web"`
+ DeviceName string `gorm:"column:device_name;comment:设备展示名"`
+ AppVersion string `gorm:"column:app_version;comment:客户端版本"`
+ Language string `gorm:"column:language;comment:登录语言"`
+ IP string `gorm:"column:ip;comment:PROXY RealIP"`
+ CreatedAt time.Time `gorm:"column:created_at;comment:本次登录时间"`
+}
+
+func (UserLoginLog) TableName() string { return "user_login_logs" }
+
+func (m *Repo) InsertLoginLog(ctx context.Context, log *entity.LoginLog) error {
+ if log == nil {
+ return nil
+ }
+ db, err := m.session(ctx)
+ if err != nil {
+ return err
+ }
+ row := UserLoginLog{
+ UserID: log.UserID,
+ DeviceID: log.DeviceID,
+ DeviceType: log.DeviceType,
+ DeviceName: log.DeviceName,
+ AppVersion: log.AppVersion,
+ Language: log.Language,
+ IP: log.IP,
+ CreatedAt: log.CreatedAt,
+ }
+ if err := db.Create(&row).Error; err != nil {
+ return mapDBErr(err)
+ }
+ log.ID = row.ID
+ return nil
+}
diff --git a/internal/module/user/service.go b/internal/module/user/service.go
deleted file mode 100644
index ae5c53d..0000000
--- a/internal/module/user/service.go
+++ /dev/null
@@ -1,12 +0,0 @@
-package user
-
-import "context"
-
-type User struct {
- ID int64
- Nickname string
-}
-
-type Service interface {
- Get(ctx context.Context, id int64) (*User, error)
-}
diff --git a/internal/pkg/errs/apperror.go b/internal/pkg/errs/apperror.go
new file mode 100644
index 0000000..ee553c6
--- /dev/null
+++ b/internal/pkg/errs/apperror.go
@@ -0,0 +1,108 @@
+package errs
+
+import (
+ "context"
+ "errors"
+ "fmt"
+)
+
+// AppError is the process-wide error type. Transport maps Code/Msg into the {code,msg,data} envelope.
+// InnerMsg is log-only and must not be sent to clients.
+type AppError struct {
+ Code string `json:"code"`
+ Msg string `json:"msg"`
+ InnerMsg string `json:"inner_msg"`
+ LogPrint bool `json:"log_print"`
+ custom bool
+}
+
+func (e *AppError) Error() string {
+ if e == nil {
+ return ""
+ }
+ return fmt.Sprintf("[%s]:%s:%s", e.Code, e.Msg, e.InnerMsg)
+}
+
+func NewError(code, msg, innerMsg string) *AppError {
+ return &AppError{Code: code, Msg: msg, InnerMsg: innerMsg}
+}
+
+func NewErrorPrint(code, msg, innerMsg string) *AppError {
+ return &AppError{Code: code, Msg: msg, InnerMsg: innerMsg, LogPrint: true}
+}
+
+func (e *AppError) CodeMsg() (string, string, string) {
+ if e == nil {
+ return "", "", ""
+ }
+ return e.Code, e.Msg, e.InnerMsg
+}
+
+// WithMsg sets the user-visible message.
+func (e *AppError) WithMsg(msg string) *AppError {
+ if e == nil {
+ return nil
+ }
+ return &AppError{Code: e.Code, Msg: msg, InnerMsg: e.InnerMsg, LogPrint: e.LogPrint, custom: true}
+}
+
+// CustomMsg is true when Msg was set by WithMsg and must not be replaced by i18n.
+func (e *AppError) CustomMsg() bool {
+ return e != nil && e.custom && e.Msg != ""
+}
+
+// WithInnerMsg sets the log-only internal message.
+func (e *AppError) WithInnerMsg(innerMsg string) *AppError {
+ if e == nil {
+ return nil
+ }
+ return &AppError{Code: e.Code, Msg: e.Msg, InnerMsg: innerMsg, LogPrint: e.LogPrint, custom: e.custom}
+}
+
+// WithError sets InnerMsg from a downstream error.
+func (e *AppError) WithError(serviceName string, err error) *AppError {
+ if e == nil {
+ return nil
+ }
+ return &AppError{
+ Code: e.Code,
+ Msg: e.Msg,
+ InnerMsg: fmt.Sprintf("[%s] err:%v", serviceName, err),
+ LogPrint: e.LogPrint,
+ custom: e.custom,
+ }
+}
+
+func (e *AppError) Is(target error) bool {
+ t, ok := target.(*AppError)
+ if !ok || e == nil || t == nil {
+ return false
+ }
+ return e.Code == t.Code
+}
+
+func AsAppError(err error) (*AppError, bool) {
+ var app *AppError
+ if errors.As(err, &app) && app != nil {
+ return app, true
+ }
+ return nil, false
+}
+
+// FromRPCError maps a downstream call error. This process has no gRPC; context and AppError are used.
+func FromRPCError(serviceName string, err error) *AppError {
+ if err == nil {
+ return nil
+ }
+ if app, ok := AsAppError(err); ok {
+ return app
+ }
+ switch {
+ case errors.Is(err, context.DeadlineExceeded):
+ return ErrTimeout.WithInnerMsg(fmt.Sprintf("[%s] timeout", serviceName))
+ case errors.Is(err, context.Canceled):
+ return ErrOperatFail.WithInnerMsg(fmt.Sprintf("[%s] canceled", serviceName))
+ default:
+ return ErrInner.WithInnerMsg(fmt.Sprintf("[%s] unexpected error: %v", serviceName, err))
+ }
+}
diff --git a/internal/pkg/errs/apperror_test.go b/internal/pkg/errs/apperror_test.go
new file mode 100644
index 0000000..fcc91e6
--- /dev/null
+++ b/internal/pkg/errs/apperror_test.go
@@ -0,0 +1,39 @@
+package errs
+
+import (
+ "context"
+ "errors"
+ "testing"
+)
+
+func TestAppErrorIsAndWith(t *testing.T) {
+ err := ErrNotFound.WithMsg("user gone").WithInnerMsg("uid=9")
+ if !errors.Is(err, ErrNotFound) {
+ t.Fatal("Is by code")
+ }
+ if errors.Is(err, ErrUnauthorized) {
+ t.Fatal("different code")
+ }
+ if err.Msg != "user gone" || ErrNotFound.Msg == "user gone" {
+ t.Fatal("WithMsg must copy")
+ }
+ code, msg, inner := err.CodeMsg()
+ if code != "40401" || msg != "user gone" || inner != "uid=9" {
+ t.Fatalf("CodeMsg %s %s %s", code, msg, inner)
+ }
+}
+
+func TestFromRPCError(t *testing.T) {
+ if FromRPCError("s", nil) != nil {
+ t.Fatal("nil")
+ }
+ if got := FromRPCError("s", ErrBusy); !errors.Is(got, ErrBusy) {
+ t.Fatal("passthrough")
+ }
+ if got := FromRPCError("user", context.DeadlineExceeded); !errors.Is(got, ErrTimeout) {
+ t.Fatal("timeout")
+ }
+ if got := FromRPCError("user", errors.New("boom")); !errors.Is(got, ErrInner) {
+ t.Fatal("inner")
+ }
+}
diff --git a/internal/pkg/errs/codes.go b/internal/pkg/errs/codes.go
new file mode 100644
index 0000000..0b33b40
--- /dev/null
+++ b/internal/pkg/errs/codes.go
@@ -0,0 +1,28 @@
+package errs
+
+// Sentinel AppErrors. Code is the envelope code shared by HTTP and TCP.
+var (
+ OK = NewError("0", "ok", "")
+ ErrInvalidArgument = NewError("40001", "invalid argument", "")
+ ErrFrameTooLarge = NewError("40002", "frame too large", "")
+ ErrInvalidFrame = NewError("40003", "invalid frame", "")
+ ErrProxyHeader = NewError("40004", "invalid proxy protocol header", "")
+ ErrUnauthorized = NewError("40101", "unauthorized", "")
+ ErrNotAuthenticated = NewError("40102", "not authenticated", "")
+ ErrForbidden = NewError("40301", "forbidden", "")
+ ErrNotFound = NewError("40401", "not found", "")
+ ErrMediaExpired = NewError("40402", "media expired", "")
+ ErrDuplicate = NewError("40901", "duplicate", "")
+ ErrBusy = NewError("42901", "busy", "")
+ ErrRateLimited = NewError("42902", "rate limited", "")
+ ErrOutboundDropped = NewError("42903", "outbound dropped", "")
+ ErrClosed = NewError("49901", "connection closed", "")
+ ErrInner = NewErrorPrint("50001", "internal", "")
+ ErrOperatFail = NewErrorPrint("50002", "operation failed", "")
+ ErrClockRewind = NewErrorPrint("50003", "snowflake clock rewind", "")
+ ErrAvailable = NewErrorPrint("50301", "unavailable", "")
+ ErrTimeout = NewErrorPrint("50401", "timeout", "")
+)
+
+// ErrInternal is an alias of ErrInner.
+var ErrInternal = ErrInner
diff --git a/internal/pkg/errs/errs.go b/internal/pkg/errs/errs.go
index 803ab70..09052ae 100644
--- a/internal/pkg/errs/errs.go
+++ b/internal/pkg/errs/errs.go
@@ -1,22 +1,2 @@
-// Package errs is the shared domain error set. Transport maps these to protocol codes.
+// Package errs is the shared domain error set. Transport maps these to the {code,msg,data} envelope.
package errs
-
-import "errors"
-
-var (
- ErrUnauthorized = errors.New("unauthorized")
- ErrNotAuthenticated = errors.New("not authenticated")
- ErrForbidden = errors.New("forbidden")
- ErrNotFound = errors.New("not found")
- ErrInvalidArgument = errors.New("invalid argument")
- ErrDuplicate = errors.New("duplicate")
- ErrRateLimited = errors.New("rate limited")
- ErrBusy = errors.New("busy")
- ErrInternal = errors.New("internal")
- ErrFrameTooLarge = errors.New("frame too large")
- ErrInvalidFrame = errors.New("invalid frame")
- ErrProxyHeader = errors.New("invalid proxy protocol header")
- ErrClockRewind = errors.New("snowflake clock rewind")
- ErrClosed = errors.New("connection closed")
- ErrOutboundDropped = errors.New("outbound dropped")
-)
diff --git a/internal/pkg/i18n/catalog.go b/internal/pkg/i18n/catalog.go
new file mode 100644
index 0000000..e414f5e
--- /dev/null
+++ b/internal/pkg/i18n/catalog.go
@@ -0,0 +1,9 @@
+package i18n
+
+var catalog = map[string]map[Lang]string{}
+
+func register(entries map[string]map[Lang]string) {
+ for k, v := range entries {
+ catalog[k] = v
+ }
+}
diff --git a/internal/pkg/i18n/catalog_err.go b/internal/pkg/i18n/catalog_err.go
new file mode 100644
index 0000000..b4856bb
--- /dev/null
+++ b/internal/pkg/i18n/catalog_err.go
@@ -0,0 +1,66 @@
+package i18n
+
+func init() {
+ register(map[string]map[Lang]string{
+ KeyOK: {
+ ZhCN: "ok", En: "ok", ZhTW: "ok",
+ },
+ "err.40001": {
+ ZhCN: "参数错误", En: "invalid argument", ZhTW: "參數錯誤",
+ },
+ "err.40002": {
+ ZhCN: "数据过大", En: "frame too large", ZhTW: "資料過大",
+ },
+ "err.40003": {
+ ZhCN: "数据格式错误", En: "invalid frame", ZhTW: "資料格式錯誤",
+ },
+ "err.40004": {
+ ZhCN: "代理协议错误", En: "invalid proxy protocol header", ZhTW: "代理協定錯誤",
+ },
+ "err.40101": {
+ ZhCN: "未授权", En: "unauthorized", ZhTW: "未授權",
+ },
+ "err.40102": {
+ ZhCN: "未登录", En: "not authenticated", ZhTW: "未登入",
+ },
+ "err.40301": {
+ ZhCN: "无权限", En: "forbidden", ZhTW: "無權限",
+ },
+ "err.40401": {
+ ZhCN: "未找到", En: "not found", ZhTW: "未找到",
+ },
+ "err.40402": {
+ ZhCN: "原文件已过期", En: "media expired", ZhTW: "原檔案已過期",
+ },
+ "err.40901": {
+ ZhCN: "重复", En: "duplicate", ZhTW: "重複",
+ },
+ "err.42901": {
+ ZhCN: "系统繁忙", En: "busy", ZhTW: "系統繁忙",
+ },
+ "err.42902": {
+ ZhCN: "请求过于频繁", En: "rate limited", ZhTW: "請求過於頻繁",
+ },
+ "err.42903": {
+ ZhCN: "发送队列已满", En: "outbound dropped", ZhTW: "傳送佇列已滿",
+ },
+ "err.49901": {
+ ZhCN: "连接已关闭", En: "connection closed", ZhTW: "連線已關閉",
+ },
+ "err.50001": {
+ ZhCN: "内部错误", En: "internal", ZhTW: "內部錯誤",
+ },
+ "err.50002": {
+ ZhCN: "操作失败", En: "operation failed", ZhTW: "操作失敗",
+ },
+ "err.50003": {
+ ZhCN: "时钟回拨", En: "snowflake clock rewind", ZhTW: "時鐘回撥",
+ },
+ "err.50301": {
+ ZhCN: "服务不可用", En: "unavailable", ZhTW: "服務不可用",
+ },
+ "err.50401": {
+ ZhCN: "超时", En: "timeout", ZhTW: "逾時",
+ },
+ })
+}
diff --git a/internal/pkg/i18n/catalog_notify.go b/internal/pkg/i18n/catalog_notify.go
new file mode 100644
index 0000000..8666063
--- /dev/null
+++ b/internal/pkg/i18n/catalog_notify.go
@@ -0,0 +1,42 @@
+package i18n
+
+func init() {
+ register(map[string]map[Lang]string{
+ NotifyKickReplaced: {
+ ZhCN: "已在其他设备登录", En: "Signed in on another device", ZhTW: "已在其他裝置登入",
+ },
+ NotifyKickLogout: {
+ ZhCN: "已退出登录", En: "Signed out", ZhTW: "已登出",
+ },
+ NotifyFriendRequest + ".title": {
+ ZhCN: "好友请求", En: "Friend request", ZhTW: "好友請求",
+ },
+ NotifyFriendRequest + ".body": {
+ ZhCN: "{name} 请求添加你为好友", En: "{name} sent you a friend request", ZhTW: "{name} 請求加入你好友",
+ },
+ NotifyFriendBecame + ".title": {
+ ZhCN: "已成为好友", En: "You are now friends", ZhTW: "已成為好友",
+ },
+ NotifyFriendBecame + ".body": {
+ ZhCN: "你们已成为好友", En: "You are now friends", ZhTW: "你們已成為好友",
+ },
+ NotifyGroupInvite + ".title": {
+ ZhCN: "群邀请", En: "Group invite", ZhTW: "群邀請",
+ },
+ NotifyGroupInvite + ".body": {
+ ZhCN: "{name} 邀请你加入 {group}", En: "{name} invited you to {group}", ZhTW: "{name} 邀請你加入 {group}",
+ },
+ NotifyGroupRemoved + ".title": {
+ ZhCN: "移出群聊", En: "Removed from group", ZhTW: "移出群組",
+ },
+ NotifyGroupRemoved + ".body": {
+ ZhCN: "你已被移出 {group}", En: "You were removed from {group}", ZhTW: "你已被移出 {group}",
+ },
+ NotifyMessageRecall + ".title": {
+ ZhCN: "消息撤回", En: "Message recalled", ZhTW: "訊息撤回",
+ },
+ NotifyMessageRecall + ".body": {
+ ZhCN: "有一条消息被撤回", En: "A message was recalled", ZhTW: "有一則訊息被撤回",
+ },
+ })
+}
diff --git a/internal/pkg/i18n/catalog_validate.go b/internal/pkg/i18n/catalog_validate.go
new file mode 100644
index 0000000..50ba9f6
--- /dev/null
+++ b/internal/pkg/i18n/catalog_validate.go
@@ -0,0 +1,96 @@
+package i18n
+
+func init() {
+ register(map[string]map[Lang]string{
+ KeyValidateInvalidJSON: {
+ ZhCN: "请求体不是合法 JSON", En: "request body is not valid JSON", ZhTW: "請求體不是合法 JSON",
+ },
+ KeyValidateFailed: {
+ ZhCN: "参数校验失败", En: "validation failed", ZhTW: "參數校驗失敗",
+ },
+ KeyValidateRequired: {
+ ZhCN: "{label}不能为空", En: "{label} is required", ZhTW: "{label}不能為空",
+ },
+ KeyValidateLen: {
+ ZhCN: "{label}长度必须是{param}", En: "{label} must be length {param}", ZhTW: "{label}長度必須是{param}",
+ },
+ KeyValidateMin: {
+ ZhCN: "{label}不能少于{param}", En: "{label} must be at least {param}", ZhTW: "{label}不能少於{param}",
+ },
+ KeyValidateMax: {
+ ZhCN: "{label}不能大于{param}", En: "{label} must be at most {param}", ZhTW: "{label}不能大於{param}",
+ },
+ KeyValidateGt: {
+ ZhCN: "{label}必须大于{param}", En: "{label} must be greater than {param}", ZhTW: "{label}必須大於{param}",
+ },
+ KeyValidateGte: {
+ ZhCN: "{label}必须大于等于{param}", En: "{label} must be greater than or equal to {param}", ZhTW: "{label}必須大於等於{param}",
+ },
+ KeyValidateLt: {
+ ZhCN: "{label}必须小于{param}", En: "{label} must be less than {param}", ZhTW: "{label}必須小於{param}",
+ },
+ KeyValidateLte: {
+ ZhCN: "{label}必须小于等于{param}", En: "{label} must be less than or equal to {param}", ZhTW: "{label}必須小於等於{param}",
+ },
+ KeyValidateEmail: {
+ ZhCN: "{label}格式不正确", En: "{label} is not a valid email", ZhTW: "{label}格式不正確",
+ },
+ KeyValidateOneof: {
+ ZhCN: "{label}必须是{param}之一", En: "{label} must be one of {param}", ZhTW: "{label}必須是{param}之一",
+ },
+ KeyValidateDefault: {
+ ZhCN: "{label}校验失败", En: "{label} is invalid", ZhTW: "{label}校驗失敗",
+ },
+ KeyFieldPhone: {
+ ZhCN: "手机号", En: "Phone number", ZhTW: "手機號",
+ },
+ KeyFieldPassword: {
+ ZhCN: "密码", En: "Password", ZhTW: "密碼",
+ },
+ KeyFieldDeviceID: {
+ ZhCN: "设备ID", En: "Device ID", ZhTW: "裝置ID",
+ },
+ KeyFieldEmail: {
+ ZhCN: "邮箱", En: "Email", ZhTW: "郵箱",
+ },
+ KeyFieldVerifyCode: {
+ ZhCN: "验证码", En: "Verification code", ZhTW: "驗證碼",
+ },
+ KeyFieldSparkID: {
+ ZhCN: "Spark账号", En: "Spark ID", ZhTW: "Spark帳號",
+ },
+ KeyFieldPlatform: {
+ ZhCN: "平台", En: "Platform", ZhTW: "平台",
+ },
+ KeyFieldDeviceName: {
+ ZhCN: "设备名称", En: "Device name", ZhTW: "裝置名稱",
+ },
+ KeyLoginPhoneRequired: {
+ ZhCN: "请填写手机号", En: "Please enter your phone number", ZhTW: "請填寫手機號",
+ },
+ KeyLoginPasswordRequired: {
+ ZhCN: "请填写密码", En: "Please enter your password", ZhTW: "請填寫密碼",
+ },
+ KeyLoginPasswordMin: {
+ ZhCN: "密码至少6位", En: "Password must be at least 6 characters", ZhTW: "密碼至少6位",
+ },
+ KeyLoginDeviceRequired: {
+ ZhCN: "请选择设备", En: "Please choose a device", ZhTW: "請選擇裝置",
+ },
+ KeyLoginEmailRequired: {
+ ZhCN: "请填写邮箱", En: "Please enter your email", ZhTW: "請填寫郵箱",
+ },
+ KeyLoginEmailInvalid: {
+ ZhCN: "邮箱格式不正确", En: "Please enter a valid email", ZhTW: "郵箱格式不正確",
+ },
+ KeyLoginCodeRequired: {
+ ZhCN: "请填写验证码", En: "Please enter the verification code", ZhTW: "請填寫驗證碼",
+ },
+ KeyUserSparkIDRequired: {
+ ZhCN: "请填写Spark账号", En: "Please enter a Spark ID", ZhTW: "請填寫Spark帳號",
+ },
+ KeyUserSparkIDInvalid: {
+ ZhCN: "Spark账号需以字母开头,6-32位字母数字或下划线", En: "Spark ID must start with a letter and be 6-32 letters, digits, or underscores", ZhTW: "Spark帳號需以字母開頭,6-32位字母數字或底線",
+ },
+ })
+}
diff --git a/internal/pkg/i18n/catalog_validate_friend.go b/internal/pkg/i18n/catalog_validate_friend.go
new file mode 100644
index 0000000..94508a0
--- /dev/null
+++ b/internal/pkg/i18n/catalog_validate_friend.go
@@ -0,0 +1,66 @@
+package i18n
+
+func init() {
+ register(map[string]map[Lang]string{
+ KeyFieldUID: {
+ ZhCN: "用户", En: "User", ZhTW: "使用者",
+ },
+ KeyFieldQRCode: {
+ ZhCN: "二维码", En: "QR code", ZhTW: "二維碼",
+ },
+ KeyFieldReason: {
+ ZhCN: "申请理由", En: "Request message", ZhTW: "申請理由",
+ },
+ KeyFieldRemark: {
+ ZhCN: "备注", En: "Remark", ZhTW: "備註",
+ },
+ KeyFieldRequestID: {
+ ZhCN: "好友申请", En: "Friend request", ZhTW: "好友申請",
+ },
+ KeyFieldReportReason: {
+ ZhCN: "投诉类型", En: "Report reason", ZhTW: "投訴類型",
+ },
+ KeyFieldReportDetail: {
+ ZhCN: "投诉说明", En: "Report detail", ZhTW: "投訴說明",
+ },
+ KeyFriendTargetRequired: {
+ ZhCN: "请填写邮箱、Spark账号或二维码其中一项", En: "Provide exactly one of email, Spark ID, or QR code", ZhTW: "請填寫郵箱、Spark帳號或二維碼其中一項",
+ },
+ KeyFriendQRInvalid: {
+ ZhCN: "二维码无效", En: "Invalid QR code", ZhTW: "二維碼無效",
+ },
+ KeyFriendReasonInvalid: {
+ ZhCN: "申请理由过长", En: "Request message is too long", ZhTW: "申請理由過長",
+ },
+ KeyFriendRemarkInvalid: {
+ ZhCN: "备注过长", En: "Remark is too long", ZhTW: "備註過長",
+ },
+ KeyFriendRequestRequired: {
+ ZhCN: "请选择好友申请", En: "Please choose a friend request", ZhTW: "請選擇好友申請",
+ },
+ KeyFriendReportReasonRequired: {
+ ZhCN: "请选择投诉类型", En: "Please choose a report reason", ZhTW: "請選擇投訴類型",
+ },
+ KeyFriendReportReasonInvalid: {
+ ZhCN: "投诉类型无效", En: "Invalid report reason", ZhTW: "投訴類型無效",
+ },
+ KeyFriendReportDetailInvalid: {
+ ZhCN: "投诉说明过长", En: "Report detail is too long", ZhTW: "投訴說明過長",
+ },
+ KeyFriendSelf: {
+ ZhCN: "不能添加自己为好友", En: "You cannot add yourself", ZhTW: "不能將自己加為好友",
+ },
+ KeyFriendAlready: {
+ ZhCN: "已经是好友", En: "Already friends", ZhTW: "已經是好友",
+ },
+ KeyFriendBlocked: {
+ ZhCN: "已拉黑该用户", En: "You have blocked this user", ZhTW: "已封鎖該使用者",
+ },
+ KeyFriendNotFriend: {
+ ZhCN: "还不是好友", En: "Not friends", ZhTW: "還不是好友",
+ },
+ KeyFriendRequestInvalid: {
+ ZhCN: "好友申请无效", En: "Friend request is invalid", ZhTW: "好友申請無效",
+ },
+ })
+}
diff --git a/internal/pkg/i18n/catalog_validate_group.go b/internal/pkg/i18n/catalog_validate_group.go
new file mode 100644
index 0000000..fbd8bb2
--- /dev/null
+++ b/internal/pkg/i18n/catalog_validate_group.go
@@ -0,0 +1,60 @@
+package i18n
+
+func init() {
+ register(map[string]map[Lang]string{
+ KeyFieldGroupName: {
+ ZhCN: "群名称", En: "Group name", ZhTW: "群名稱",
+ },
+ KeyFieldMemberUIDs: {
+ ZhCN: "群成员", En: "Group members", ZhTW: "群成員",
+ },
+ KeyFieldRole: {
+ ZhCN: "角色", En: "Role", ZhTW: "角色",
+ },
+ KeyFieldGroupNickname: {
+ ZhCN: "群昵称", En: "Group nickname", ZhTW: "群暱稱",
+ },
+ KeyGroupNameRequired: {
+ ZhCN: "请填写群名称", En: "Please enter a group name", ZhTW: "請填寫群名稱",
+ },
+ KeyGroupNameInvalid: {
+ ZhCN: "群名称需 1-32 个字符", En: "Group name must be 1-32 characters", ZhTW: "群名稱需 1-32 個字元",
+ },
+ KeyGroupMembersRequired: {
+ ZhCN: "请选择至少一名好友", En: "Choose at least one friend", ZhTW: "請選擇至少一名好友",
+ },
+ KeyGroupMembersInvalid: {
+ ZhCN: "邀请人数过多", En: "Too many members", ZhTW: "邀請人數過多",
+ },
+ KeyGroupUIDRequired: {
+ ZhCN: "请选择成员", En: "Please choose a member", ZhTW: "請選擇成員",
+ },
+ KeyGroupRoleInvalid: {
+ ZhCN: "角色无效", En: "Invalid role", ZhTW: "角色無效",
+ },
+ KeyGroupNicknameInvalid: {
+ ZhCN: "群昵称过长", En: "Group nickname is too long", ZhTW: "群暱稱過長",
+ },
+ KeyGroupNotMember: {
+ ZhCN: "不是群成员", En: "Not a group member", ZhTW: "不是群成員",
+ },
+ KeyGroupNotFriend: {
+ ZhCN: "只能邀请好友入群", En: "You can only invite friends", ZhTW: "只能邀請好友入群",
+ },
+ KeyGroupOwnerLeave: {
+ ZhCN: "群主需先转让群主", En: "Transfer ownership before leaving", ZhTW: "群主需先轉讓群主",
+ },
+ KeyGroupFull: {
+ ZhCN: "群人数已满", En: "Group is full", ZhTW: "群人數已滿",
+ },
+ KeyGroupDissolved: {
+ ZhCN: "群已解散", En: "Group has been dissolved", ZhTW: "群已解散",
+ },
+ KeyGroupRoleDenied: {
+ ZhCN: "没有操作权限", En: "You cannot do that", ZhTW: "沒有操作權限",
+ },
+ KeyGroupAlreadyMember: {
+ ZhCN: "已经是群成员", En: "Already a group member", ZhTW: "已經是群成員",
+ },
+ })
+}
diff --git a/internal/pkg/i18n/catalog_validate_upload.go b/internal/pkg/i18n/catalog_validate_upload.go
new file mode 100644
index 0000000..b6e6ccd
--- /dev/null
+++ b/internal/pkg/i18n/catalog_validate_upload.go
@@ -0,0 +1,60 @@
+package i18n
+
+func init() {
+ register(map[string]map[Lang]string{
+ KeyFieldPurpose: {
+ ZhCN: "用途", En: "Purpose", ZhTW: "用途",
+ },
+ KeyFieldMime: {
+ ZhCN: "文件类型", En: "MIME type", ZhTW: "檔案類型",
+ },
+ KeyFieldSize: {
+ ZhCN: "文件大小", En: "File size", ZhTW: "檔案大小",
+ },
+ KeyFieldObjectKey: {
+ ZhCN: "对象键", En: "Object key", ZhTW: "物件鍵",
+ },
+ KeyFieldMsgID: {
+ ZhCN: "消息", En: "Message", ZhTW: "訊息",
+ },
+ KeyUploadPurposeRequired: {
+ ZhCN: "请选择上传用途", En: "Please choose an upload purpose", ZhTW: "請選擇上傳用途",
+ },
+ KeyUploadPurposeInvalid: {
+ ZhCN: "上传用途无效", En: "Invalid upload purpose", ZhTW: "上傳用途無效",
+ },
+ KeyUploadMIMERequired: {
+ ZhCN: "请填写文件类型", En: "Please provide a MIME type", ZhTW: "請填寫檔案類型",
+ },
+ KeyUploadMIMEInvalid: {
+ ZhCN: "不支持该文件类型", En: "This file type is not allowed", ZhTW: "不支援該檔案類型",
+ },
+ KeyUploadSizeRequired: {
+ ZhCN: "请填写文件大小", En: "Please provide the file size", ZhTW: "請填寫檔案大小",
+ },
+ KeyUploadSizeInvalid: {
+ ZhCN: "文件大小无效", En: "Invalid file size", ZhTW: "檔案大小無效",
+ },
+ KeyUploadSizeExceeded: {
+ ZhCN: "文件过大", En: "File is too large", ZhTW: "檔案過大",
+ },
+ KeyUploadKeyRequired: {
+ ZhCN: "请填写对象键", En: "Please provide an object key", ZhTW: "請填寫物件鍵",
+ },
+ KeyUploadKeyInvalid: {
+ ZhCN: "对象键无效", En: "Invalid object key", ZhTW: "物件鍵無效",
+ },
+ KeyFieldGroupID: {
+ ZhCN: "群", En: "Group", ZhTW: "群",
+ },
+ KeyUploadGroupRequired: {
+ ZhCN: "请选择群", En: "Please choose a group", ZhTW: "請選擇群",
+ },
+ KeyFavoriteMsgRequired: {
+ ZhCN: "请选择消息", En: "Please choose a message", ZhTW: "請選擇訊息",
+ },
+ KeyFavoriteMsgInvalid: {
+ ZhCN: "消息无效", En: "Invalid message", ZhTW: "訊息無效",
+ },
+ })
+}
diff --git a/internal/pkg/i18n/catalog_validate_user.go b/internal/pkg/i18n/catalog_validate_user.go
new file mode 100644
index 0000000..ed17723
--- /dev/null
+++ b/internal/pkg/i18n/catalog_validate_user.go
@@ -0,0 +1,45 @@
+package i18n
+
+func init() {
+ register(map[string]map[Lang]string{
+ KeyFieldDeviceType: {
+ ZhCN: "设备类型", En: "Device type", ZhTW: "裝置類型",
+ },
+ KeyFieldRefreshToken: {
+ ZhCN: "刷新令牌", En: "Refresh token", ZhTW: "重新整理令牌",
+ },
+ KeyFieldNickname: {
+ ZhCN: "昵称", En: "Nickname", ZhTW: "暱稱",
+ },
+ KeyFieldAvatar: {
+ ZhCN: "头像", En: "Avatar", ZhTW: "頭像",
+ },
+ KeyFieldGender: {
+ ZhCN: "性别", En: "Gender", ZhTW: "性別",
+ },
+ KeyFieldBirthday: {
+ ZhCN: "生日", En: "Birthday", ZhTW: "生日",
+ },
+ KeyUserNicknameInvalid: {
+ ZhCN: "昵称长度为1-64", En: "Nickname must be 1-64 characters", ZhTW: "暱稱長度為1-64",
+ },
+ KeyUserAvatarInvalid: {
+ ZhCN: "头像地址过长", En: "Avatar is too long", ZhTW: "頭像地址過長",
+ },
+ KeyUserGenderInvalid: {
+ ZhCN: "性别取值无效", En: "Invalid gender", ZhTW: "性別取值無效",
+ },
+ KeyUserBirthdayInvalid: {
+ ZhCN: "生日格式为YYYY-MM-DD", En: "Birthday must be YYYY-MM-DD", ZhTW: "生日格式為YYYY-MM-DD",
+ },
+ KeyLoginDeviceTypeRequired: {
+ ZhCN: "请选择设备类型", En: "Please choose a device type", ZhTW: "請選擇裝置類型",
+ },
+ KeyLoginDeviceTypeInvalid: {
+ ZhCN: "设备类型无效", En: "Invalid device type", ZhTW: "裝置類型無效",
+ },
+ KeyLoginRefreshRequired: {
+ ZhCN: "请填写刷新令牌", En: "Please enter the refresh token", ZhTW: "請填寫重新整理令牌",
+ },
+ })
+}
diff --git a/internal/pkg/i18n/i18n_test.go b/internal/pkg/i18n/i18n_test.go
new file mode 100644
index 0000000..2d3accf
--- /dev/null
+++ b/internal/pkg/i18n/i18n_test.go
@@ -0,0 +1,41 @@
+package i18n
+
+import "testing"
+
+func TestParseLanguage(t *testing.T) {
+ cases := map[string]Lang{
+ "": ZhCN,
+ "zh": ZhCN,
+ "zh-CN": ZhCN,
+ "zh_CN": ZhCN,
+ "zh-Hans": ZhCN,
+ "en": En,
+ "en-US,en;q=0.9": En,
+ "zh-TW": ZhTW,
+ "zh-HK": ZhTW,
+ "zh-Hant-TW": ZhTW,
+ "fr,en;q=0.8": En,
+ "zh-CN,zh;q=0.9,en;q=0.8": ZhCN,
+ }
+ for in, want := range cases {
+ if got := Parse(in); got != want {
+ t.Fatalf("Parse(%q)=%s want %s", in, got, want)
+ }
+ }
+}
+
+func TestErrorAndNotifyI18n(t *testing.T) {
+ if ErrorMsg(ZhCN, "40101") != "未授权" || ErrorMsg(En, "40101") != "unauthorized" || ErrorMsg(ZhTW, "40101") != "未授權" {
+ t.Fatalf("err 40101 %s %s %s", ErrorMsg(ZhCN, "40101"), ErrorMsg(En, "40101"), ErrorMsg(ZhTW, "40101"))
+ }
+ if ErrorMsg(ZhCN, "40402") != "原文件已过期" || ErrorMsg(En, "40402") != "media expired" {
+ t.Fatalf("err 40402 %s %s", ErrorMsg(ZhCN, "40402"), ErrorMsg(En, "40402"))
+ }
+ title, body := Notify(En, NotifyFriendRequest, map[string]string{"name": "Ada"})
+ if title != "Friend request" || body != "Ada sent you a friend request" {
+ t.Fatalf("notify %q %q", title, body)
+ }
+ if T(ZhTW, NotifyKickReplaced, nil) != "已在其他裝置登入" {
+ t.Fatalf("kick %s", T(ZhTW, NotifyKickReplaced, nil))
+ }
+}
diff --git a/internal/pkg/i18n/keys.go b/internal/pkg/i18n/keys.go
new file mode 100644
index 0000000..1592158
--- /dev/null
+++ b/internal/pkg/i18n/keys.go
@@ -0,0 +1,119 @@
+package i18n
+
+// Message keys. Error keys are "err." + AppError.Code.
+const (
+ KeyOK = "ok"
+
+ KeyValidateInvalidJSON = "validate.invalid_json"
+ KeyValidateFailed = "validate.failed"
+ KeyValidateRequired = "validate.required"
+ KeyValidateLen = "validate.len"
+ KeyValidateMin = "validate.min"
+ KeyValidateMax = "validate.max"
+ KeyValidateGt = "validate.gt"
+ KeyValidateGte = "validate.gte"
+ KeyValidateLt = "validate.lt"
+ KeyValidateLte = "validate.lte"
+ KeyValidateEmail = "validate.email"
+ KeyValidateOneof = "validate.oneof"
+ KeyValidateDefault = "validate.default"
+
+ KeyFieldPhone = "field.phone"
+ KeyFieldPassword = "field.password"
+ KeyFieldDeviceID = "field.device_id"
+ KeyFieldEmail = "field.email"
+ KeyFieldVerifyCode = "field.verify_code"
+ KeyFieldSparkID = "field.spark_id"
+ KeyFieldPlatform = "field.platform"
+ KeyFieldDeviceName = "field.device_name"
+ KeyFieldDeviceType = "field.device_type"
+ KeyFieldRefreshToken = "field.refresh_token"
+ KeyFieldNickname = "field.nickname"
+ KeyFieldAvatar = "field.avatar"
+ KeyFieldGender = "field.gender"
+ KeyFieldBirthday = "field.birthday"
+ KeyFieldUID = "field.uid"
+ KeyFieldQRCode = "field.qrcode"
+ KeyFieldReason = "field.reason"
+ KeyFieldRemark = "field.remark"
+ KeyFieldRequestID = "field.request_id"
+ KeyFieldReportReason = "field.report_reason"
+ KeyFieldReportDetail = "field.report_detail"
+ KeyFieldPurpose = "field.purpose"
+ KeyFieldMime = "field.mime"
+ KeyFieldSize = "field.size"
+ KeyFieldObjectKey = "field.object_key"
+ KeyFieldMsgID = "field.msg_id"
+ KeyFieldGroupID = "field.group_id"
+
+ KeyLoginPhoneRequired = "validate.login.phone_required"
+ KeyLoginPasswordRequired = "validate.login.password_required"
+ KeyLoginPasswordMin = "validate.login.password_min"
+ KeyLoginDeviceRequired = "validate.login.device_required"
+ KeyLoginEmailRequired = "validate.login.email_required"
+ KeyLoginEmailInvalid = "validate.login.email_invalid"
+ KeyLoginCodeRequired = "validate.login.code_required"
+ KeyUserSparkIDRequired = "validate.user.spark_id_required"
+ KeyUserSparkIDInvalid = "validate.user.spark_id_invalid"
+ KeyUserNicknameInvalid = "validate.user.nickname_invalid"
+ KeyUserAvatarInvalid = "validate.user.avatar_invalid"
+ KeyUserGenderInvalid = "validate.user.gender_invalid"
+ KeyUserBirthdayInvalid = "validate.user.birthday_invalid"
+ KeyLoginDeviceTypeRequired = "validate.login.device_type_required"
+ KeyLoginDeviceTypeInvalid = "validate.login.device_type_invalid"
+ KeyLoginRefreshRequired = "validate.login.refresh_required"
+
+ KeyFriendTargetRequired = "validate.friend.target_required"
+ KeyFriendQRInvalid = "validate.friend.qrcode_invalid"
+ KeyFriendReasonInvalid = "validate.friend.reason_invalid"
+ KeyFriendRemarkInvalid = "validate.friend.remark_invalid"
+ KeyFriendRequestRequired = "validate.friend.request_required"
+ KeyFriendReportReasonRequired = "validate.friend.report_reason_required"
+ KeyFriendReportReasonInvalid = "validate.friend.report_reason_invalid"
+ KeyFriendReportDetailInvalid = "validate.friend.report_detail_invalid"
+ KeyFriendSelf = "friend.self"
+ KeyFriendAlready = "friend.already"
+ KeyFriendBlocked = "friend.blocked"
+ KeyFriendNotFriend = "friend.not_friend"
+ KeyFriendRequestInvalid = "friend.request_invalid"
+
+ KeyUploadPurposeRequired = "validate.upload.purpose_required"
+ KeyUploadPurposeInvalid = "validate.upload.purpose_invalid"
+ KeyUploadMIMERequired = "validate.upload.mime_required"
+ KeyUploadMIMEInvalid = "validate.upload.mime_invalid"
+ KeyUploadSizeRequired = "validate.upload.size_required"
+ KeyUploadSizeInvalid = "validate.upload.size_invalid"
+ KeyUploadSizeExceeded = "validate.upload.size_exceeded"
+ KeyUploadKeyRequired = "validate.upload.key_required"
+ KeyUploadKeyInvalid = "validate.upload.key_invalid"
+ KeyUploadGroupRequired = "validate.upload.group_required"
+ KeyFavoriteMsgRequired = "validate.favorite.msg_required"
+ KeyFavoriteMsgInvalid = "validate.favorite.msg_invalid"
+
+ KeyFieldGroupName = "field.group_name"
+ KeyFieldMemberUIDs = "field.member_uids"
+ KeyFieldRole = "field.role"
+ KeyFieldGroupNickname = "field.group_nickname"
+ KeyGroupNameRequired = "validate.group.name_required"
+ KeyGroupNameInvalid = "validate.group.name_invalid"
+ KeyGroupMembersRequired = "validate.group.members_required"
+ KeyGroupMembersInvalid = "validate.group.members_invalid"
+ KeyGroupUIDRequired = "validate.group.uid_required"
+ KeyGroupRoleInvalid = "validate.group.role_invalid"
+ KeyGroupNicknameInvalid = "validate.group.nickname_invalid"
+ KeyGroupNotMember = "group.not_member"
+ KeyGroupNotFriend = "group.not_friend"
+ KeyGroupOwnerLeave = "group.owner_leave"
+ KeyGroupFull = "group.full"
+ KeyGroupDissolved = "group.dissolved"
+ KeyGroupRoleDenied = "group.role_denied"
+ KeyGroupAlreadyMember = "group.already_member"
+
+ NotifyKickReplaced = "notify.kick.replaced"
+ NotifyKickLogout = "notify.kick.logout"
+ NotifyFriendRequest = "notify.friend.request"
+ NotifyFriendBecame = "notify.friend.became"
+ NotifyGroupInvite = "notify.group.invite"
+ NotifyGroupRemoved = "notify.group.removed"
+ NotifyMessageRecall = "notify.message.recall"
+)
diff --git a/internal/pkg/i18n/locale.go b/internal/pkg/i18n/locale.go
new file mode 100644
index 0000000..a2f2a2d
--- /dev/null
+++ b/internal/pkg/i18n/locale.go
@@ -0,0 +1,50 @@
+package i18n
+
+import (
+ "strings"
+)
+
+// Lang is a canonical UI language. Only these three are served.
+type Lang string
+
+const (
+ ZhCN Lang = "zh-CN"
+ En Lang = "en"
+ ZhTW Lang = "zh-TW"
+ Default = ZhCN
+)
+
+func (l Lang) String() string {
+ if l == "" {
+ return string(Default)
+ }
+ return string(l)
+}
+
+// Parse maps Accept-Language, X-Language, or TCP AUTH language to ZhCN / En / ZhTW.
+func Parse(raw string) Lang {
+ for _, part := range strings.Split(raw, ",") {
+ tag, _, _ := strings.Cut(strings.TrimSpace(part), ";")
+ if lang, ok := match(tag); ok {
+ return lang
+ }
+ }
+ return Default
+}
+
+func match(tag string) (Lang, bool) {
+ t := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(tag), "_", "-"))
+ if t == "" {
+ return "", false
+ }
+ switch {
+ case t == "en" || strings.HasPrefix(t, "en-"):
+ return En, true
+ case t == "zh-tw" || t == "zh-hk" || t == "zh-mo" || t == "zh-hant" || strings.HasPrefix(t, "zh-hant"):
+ return ZhTW, true
+ case t == "zh" || t == "zh-cn" || t == "zh-sg" || t == "zh-hans" || strings.HasPrefix(t, "zh-hans"):
+ return ZhCN, true
+ default:
+ return "", false
+ }
+}
diff --git a/internal/pkg/i18n/text.go b/internal/pkg/i18n/text.go
new file mode 100644
index 0000000..6d2db5f
--- /dev/null
+++ b/internal/pkg/i18n/text.go
@@ -0,0 +1,62 @@
+package i18n
+
+import "strings"
+
+func T(lang Lang, key string, args map[string]string) string {
+ if key == "" {
+ return ""
+ }
+ if lang == "" {
+ lang = Default
+ }
+ s := lookup(lang, key)
+ if s == "" && lang != En {
+ s = lookup(En, key)
+ }
+ if s == "" && lang != ZhCN {
+ s = lookup(ZhCN, key)
+ }
+ if s == "" {
+ return key
+ }
+ return apply(s, args)
+}
+
+func lookup(lang Lang, key string) string {
+ byLang, ok := catalog[key]
+ if !ok {
+ return ""
+ }
+ return byLang[lang]
+}
+
+func apply(s string, args map[string]string) string {
+ for k, v := range args {
+ s = strings.ReplaceAll(s, "{"+k+"}", v)
+ }
+ return s
+}
+
+// ErrorMsg is the user-visible envelope msg for a business code.
+func ErrorMsg(lang Lang, code string) string {
+ if code == "" || code == "0" {
+ return T(lang, KeyOK, nil)
+ }
+ return T(lang, "err."+code, nil)
+}
+
+// Notify returns title and body for a notification type. Missing ".body" falls back to the type key.
+func Notify(lang Lang, typ string, args map[string]string) (title, body string) {
+ title = T(lang, typ+".title", args)
+ if title == typ+".title" {
+ title = ""
+ }
+ body = T(lang, typ+".body", args)
+ if body == typ+".body" {
+ body = T(lang, typ, args)
+ if body == typ {
+ body = ""
+ }
+ }
+ return title, body
+}
diff --git a/internal/pkg/ratelimit/ratelimit.go b/internal/pkg/ratelimit/ratelimit.go
new file mode 100644
index 0000000..f37850e
--- /dev/null
+++ b/internal/pkg/ratelimit/ratelimit.go
@@ -0,0 +1,69 @@
+package ratelimit
+
+import (
+ "sync"
+ "time"
+)
+
+// Limiter is a per-key token bucket. Keys are RealIP (C6), never gnet RemoteAddr.
+type Limiter struct {
+ mu sync.Mutex
+ rate float64
+ burst float64
+ buckets map[string]*bucket
+}
+
+type bucket struct {
+ tokens float64
+ last time.Time
+}
+
+func New(perSec int) *Limiter {
+ if perSec < 1 {
+ perSec = 5
+ }
+ return &Limiter{
+ rate: float64(perSec),
+ burst: float64(perSec),
+ buckets: make(map[string]*bucket),
+ }
+}
+
+func (l *Limiter) Allow(key string) bool {
+ if l == nil {
+ return true
+ }
+ if key == "" {
+ key = "unknown"
+ }
+ now := time.Now()
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ b := l.buckets[key]
+ if b == nil {
+ b = &bucket{tokens: l.burst, last: now}
+ l.buckets[key] = b
+ }
+ elapsed := now.Sub(b.last).Seconds()
+ b.tokens += elapsed * l.rate
+ if b.tokens > l.burst {
+ b.tokens = l.burst
+ }
+ b.last = now
+ if b.tokens < 1 {
+ return false
+ }
+ b.tokens--
+ if len(l.buckets) > 8192 {
+ l.gc(now)
+ }
+ return true
+}
+
+func (l *Limiter) gc(now time.Time) {
+ for k, b := range l.buckets {
+ if now.Sub(b.last) > time.Minute {
+ delete(l.buckets, k)
+ }
+ }
+}
diff --git a/internal/pkg/ratelimit/ratelimit_test.go b/internal/pkg/ratelimit/ratelimit_test.go
new file mode 100644
index 0000000..3baed99
--- /dev/null
+++ b/internal/pkg/ratelimit/ratelimit_test.go
@@ -0,0 +1,19 @@
+package ratelimit
+
+import "testing"
+
+func TestAllowBurstThenBlock(t *testing.T) {
+ l := New(2)
+ if !l.Allow("203.0.113.10") {
+ t.Fatal("first")
+ }
+ if !l.Allow("203.0.113.10") {
+ t.Fatal("burst")
+ }
+ if l.Allow("203.0.113.10") {
+ t.Fatal("over burst")
+ }
+ if !l.Allow("198.51.100.20") {
+ t.Fatal("other ip")
+ }
+}
diff --git a/internal/pkg/reply/reply.go b/internal/pkg/reply/reply.go
new file mode 100644
index 0000000..2bd46bf
--- /dev/null
+++ b/internal/pkg/reply/reply.go
@@ -0,0 +1,96 @@
+package reply
+
+import (
+ "context"
+ "encoding/json"
+
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
+ "spark-server/internal/pkg/reqctx"
+)
+
+// Envelope is the unified HTTP and TCP response body: {"code":"","msg":"","data":{}}.
+type Envelope struct {
+ Code string `json:"code"`
+ Msg string `json:"msg"`
+ Data json.RawMessage `json:"data"`
+}
+
+func OK(data any) Envelope {
+ return Envelope{Code: errs.OK.Code, Msg: errs.OK.Msg, Data: marshalData(data)}
+}
+
+func Fail(code, msg string) Envelope {
+ if code == "" {
+ code = errs.ErrInner.Code
+ }
+ if msg == "" {
+ msg = errs.ErrInner.Msg
+ }
+ return Envelope{Code: code, Msg: msg, Data: json.RawMessage("{}")}
+}
+
+func FailData(code, msg string, data any) Envelope {
+ env := Fail(code, msg)
+ env.Data = marshalData(data)
+ return env
+}
+
+func FromError(err error) Envelope {
+ return FromErrorLang(i18n.Default, err)
+}
+
+func FromContext(ctx context.Context, err error) Envelope {
+ return FromErrorLang(reqctx.Lang(ctx), err)
+}
+
+func FromErrorLang(lang i18n.Lang, err error) Envelope {
+ if err == nil {
+ return OK(nil)
+ }
+ if app, ok := errs.AsAppError(err); ok {
+ return Fail(app.Code, publicMsg(lang, app))
+ }
+ return Fail(errs.ErrInner.Code, i18n.ErrorMsg(lang, errs.ErrInner.Code))
+}
+
+func publicMsg(lang i18n.Lang, app *errs.AppError) string {
+ if app.CustomMsg() {
+ return app.Msg
+ }
+ return i18n.ErrorMsg(lang, app.Code)
+}
+
+func Marshal(env Envelope) ([]byte, error) {
+ return json.Marshal(env)
+}
+
+func MustMarshal(env Envelope) []byte {
+ b, err := Marshal(env)
+ if err != nil {
+ b, _ = json.Marshal(Fail(errs.ErrInner.Code, errs.ErrInner.Msg))
+ }
+ return b
+}
+
+func Unmarshal(body []byte) (Envelope, error) {
+ var env Envelope
+ if err := json.Unmarshal(body, &env); err != nil {
+ return Envelope{}, errs.ErrInvalidFrame.WithInnerMsg(err.Error())
+ }
+ if len(env.Data) == 0 {
+ env.Data = json.RawMessage("{}")
+ }
+ return env, nil
+}
+
+func marshalData(data any) json.RawMessage {
+ if data == nil {
+ return json.RawMessage("{}")
+ }
+ b, err := json.Marshal(data)
+ if err != nil || len(b) == 0 || string(b) == "null" {
+ return json.RawMessage("{}")
+ }
+ return b
+}
diff --git a/internal/pkg/reply/reply_test.go b/internal/pkg/reply/reply_test.go
new file mode 100644
index 0000000..0ae23e2
--- /dev/null
+++ b/internal/pkg/reply/reply_test.go
@@ -0,0 +1,46 @@
+package reply
+
+import (
+ "encoding/json"
+ "testing"
+
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
+)
+
+func TestOKAndFailEnvelope(t *testing.T) {
+ raw := MustMarshal(OK(map[string]int{"n": 1}))
+ var env Envelope
+ if err := json.Unmarshal(raw, &env); err != nil {
+ t.Fatal(err)
+ }
+ if env.Code != "0" || env.Msg != "ok" || string(env.Data) != `{"n":1}` {
+ t.Fatalf("%+v", env)
+ }
+ detail := FailData(errs.ErrAvailable.Code, "unavailable", map[string]string{"mysql": "down"})
+ if string(detail.Data) != `{"mysql":"down"}` {
+ t.Fatalf("data %s", detail.Data)
+ }
+ fail := FromError(errs.ErrNotAuthenticated.WithMsg("login first"))
+ if fail.Code != errs.ErrNotAuthenticated.Code || fail.Msg != "login first" || string(fail.Data) != "{}" {
+ t.Fatalf("fail %+v", fail)
+ }
+ zh := FromError(errs.ErrUnauthorized)
+ if zh.Msg != "未授权" {
+ t.Fatalf("zh %q", zh.Msg)
+ }
+ en := FromErrorLang(i18n.En, errs.ErrUnauthorized)
+ if en.Msg != "unauthorized" {
+ t.Fatalf("en %q", en.Msg)
+ }
+}
+
+func TestUnmarshal(t *testing.T) {
+ env, err := Unmarshal([]byte(`{"code":"0","msg":"ok"}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(env.Data) != "{}" {
+ t.Fatalf("empty data %s", env.Data)
+ }
+}
diff --git a/internal/pkg/reqctx/reqctx.go b/internal/pkg/reqctx/reqctx.go
index b35a924..df59c5f 100644
--- a/internal/pkg/reqctx/reqctx.go
+++ b/internal/pkg/reqctx/reqctx.go
@@ -1,12 +1,22 @@
package reqctx
-import "context"
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "net"
+ "strings"
+ "time"
+
+ "spark-server/internal/pkg/i18n"
+)
type ctxKey struct{}
// RequestContext is the per-request identity. Use RealIP for rate limit and audit, never 127.0.0.1 from gnet.RemoteAddr.
type RequestContext struct {
TraceID string
+ RequestID string
UserID int64
DeviceID uint64
Language string
@@ -14,6 +24,8 @@ type RequestContext struct {
ConnectionID string
RemoteAddr string
RealIP string
+ JTI string
+ TokenExpires time.Time
}
func With(ctx context.Context, rc *RequestContext) context.Context {
@@ -24,3 +36,59 @@ func From(ctx context.Context) *RequestContext {
rc, _ := ctx.Value(ctxKey{}).(*RequestContext)
return rc
}
+
+// RealIP returns the client IP from context (PROXY / X-Real-IP), not gnet RemoteAddr.
+func RealIP(ctx context.Context) string {
+ if rc := From(ctx); rc != nil {
+ return rc.RealIP
+ }
+ return ""
+}
+
+// Lang is the canonical UI language (zh-CN / en / zh-TW) for errors and notifications.
+func Lang(ctx context.Context) i18n.Lang {
+ if rc := From(ctx); rc != nil {
+ return i18n.Parse(rc.Language)
+ }
+ return i18n.Default
+}
+
+// Language returns the canonical language tag stored on context.
+func Language(ctx context.Context) string {
+ return Lang(ctx).String()
+}
+
+// ParseHTTPRealIP prefers X-Real-IP, then X-Forwarded-For, then RemoteAddr host.
+func ParseHTTPRealIP(realIPHeader, forwardedFor, remoteAddr string) string {
+ if ip := strings.TrimSpace(realIPHeader); ip != "" {
+ return stripHost(ip)
+ }
+ if forwardedFor != "" {
+ first, _, _ := strings.Cut(forwardedFor, ",")
+ if ip := stripHost(strings.TrimSpace(first)); ip != "" {
+ return ip
+ }
+ }
+ return stripHost(remoteAddr)
+}
+
+func stripHost(v string) string {
+ v = strings.TrimSpace(v)
+ if v == "" {
+ return ""
+ }
+ host, _, err := net.SplitHostPort(v)
+ if err == nil {
+ return host
+ }
+ return v
+}
+
+// NewID returns a 16-byte hex id for trace_id / request_id.
+func NewID() string {
+ var b [16]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ return "00000000000000000000000000000000"
+ }
+ return hex.EncodeToString(b[:])
+}
diff --git a/internal/pkg/reqctx/reqctx_test.go b/internal/pkg/reqctx/reqctx_test.go
new file mode 100644
index 0000000..f74ee7a
--- /dev/null
+++ b/internal/pkg/reqctx/reqctx_test.go
@@ -0,0 +1,38 @@
+package reqctx
+
+import "testing"
+
+func TestParseHTTPRealIP(t *testing.T) {
+ got := ParseHTTPRealIP(" 203.0.113.10 ", "198.51.100.1, 192.0.2.1", "192.0.2.1:1234")
+ if got != "203.0.113.10" {
+ t.Fatalf("x-real-ip %q", got)
+ }
+ got = ParseHTTPRealIP("", "198.51.100.1, 192.0.2.1", "192.0.2.1:1234")
+ if got != "198.51.100.1" {
+ t.Fatalf("xff %q", got)
+ }
+ got = ParseHTTPRealIP("", "", "192.0.2.1:1234")
+ if got != "192.0.2.1" {
+ t.Fatalf("remote %q", got)
+ }
+}
+
+func TestRealIPFromContext(t *testing.T) {
+ ctx := With(t.Context(), &RequestContext{RealIP: "203.0.113.10"})
+ if RealIP(ctx) != "203.0.113.10" {
+ t.Fatalf("got %q", RealIP(ctx))
+ }
+ if RealIP(t.Context()) != "" {
+ t.Fatal("empty ctx")
+ }
+}
+
+func TestLangFromContext(t *testing.T) {
+ ctx := With(t.Context(), &RequestContext{Language: "en-US"})
+ if Language(ctx) != "en" {
+ t.Fatalf("got %q", Language(ctx))
+ }
+ if Language(t.Context()) != "zh-CN" {
+ t.Fatalf("default %q", Language(t.Context()))
+ }
+}
diff --git a/internal/pkg/validate/validate.go b/internal/pkg/validate/validate.go
new file mode 100644
index 0000000..d6d0869
--- /dev/null
+++ b/internal/pkg/validate/validate.go
@@ -0,0 +1,110 @@
+package validate
+
+import (
+ "errors"
+ "reflect"
+ "strings"
+ "sync"
+
+ "github.com/go-playground/validator/v10"
+
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
+)
+
+var (
+ once sync.Once
+ v *validator.Validate
+)
+
+func engine() *validator.Validate {
+ once.Do(func() {
+ v = validator.New(validator.WithRequiredStructEnabled())
+ v.RegisterTagNameFunc(func(f reflect.StructField) string {
+ if l := f.Tag.Get("label"); l != "" {
+ return l
+ }
+ name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
+ if name != "" && name != "-" {
+ return name
+ }
+ return f.Name
+ })
+ })
+ return v
+}
+
+// Struct validates v in zh-CN. Prefer StructLang with the request language.
+func Struct(val any) error {
+ return StructLang(i18n.Default, val)
+}
+
+// StructLang validates v. `label` / `errmsg` are i18n keys (literal text still works as fallback).
+// errmsg example: `errmsg:"required:validate.login.phone_required"`
+func StructLang(lang i18n.Lang, val any) error {
+ if val == nil {
+ return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyValidateFailed, nil))
+ }
+ err := engine().Struct(val)
+ if err == nil {
+ return nil
+ }
+ var ves validator.ValidationErrors
+ if !errors.As(err, &ves) {
+ return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyValidateFailed, nil))
+ }
+ msgs := make([]string, 0, len(ves))
+ for _, fe := range ves {
+ msgs = append(msgs, fieldMessage(lang, val, fe))
+ }
+ return errs.ErrInvalidArgument.WithMsg(strings.Join(msgs, "; "))
+}
+
+func fieldMessage(lang i18n.Lang, root any, fe validator.FieldError) string {
+ label := i18n.T(lang, fe.Field(), nil)
+ if custom := errmsgFor(root, fe.StructField(), fe.Tag()); custom != "" {
+ return i18n.T(lang, custom, map[string]string{"label": label, "param": fe.Param()})
+ }
+ return defaultMessage(lang, label, fe)
+}
+
+func errmsgFor(root any, structField, tag string) string {
+ sf, ok := findField(reflect.TypeOf(root), structField)
+ if !ok {
+ return ""
+ }
+ raw := sf.Tag.Get("errmsg")
+ if raw == "" {
+ return ""
+ }
+ for _, part := range strings.Split(raw, ";") {
+ k, v, found := strings.Cut(strings.TrimSpace(part), ":")
+ if found && k == tag {
+ return v
+ }
+ }
+ return ""
+}
+
+func findField(t reflect.Type, name string) (reflect.StructField, bool) {
+ for t.Kind() == reflect.Pointer {
+ t = t.Elem()
+ }
+ if t.Kind() != reflect.Struct {
+ return reflect.StructField{}, false
+ }
+ sf, ok := t.FieldByName(name)
+ return sf, ok
+}
+
+func defaultMessage(lang i18n.Lang, label string, fe validator.FieldError) string {
+ key := "validate." + fe.Tag()
+ if lookupEmpty(lang, key) {
+ key = i18n.KeyValidateDefault
+ }
+ return i18n.T(lang, key, map[string]string{"label": label, "param": fe.Param()})
+}
+
+func lookupEmpty(lang i18n.Lang, key string) bool {
+ return i18n.T(lang, key, nil) == key
+}
diff --git a/internal/pkg/validate/validate_test.go b/internal/pkg/validate/validate_test.go
new file mode 100644
index 0000000..22c05c8
--- /dev/null
+++ b/internal/pkg/validate/validate_test.go
@@ -0,0 +1,50 @@
+package validate
+
+import (
+ "strings"
+ "testing"
+
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
+)
+
+type sample struct {
+ Phone string `json:"phone" validate:"required" label:"field.phone" errmsg:"required:validate.login.phone_required"`
+ Password string `json:"password" validate:"required,min=6" label:"field.password"`
+ DeviceID uint64 `json:"device_id" validate:"required" label:"field.device_id"`
+}
+
+func TestStructCustomAndDefaultMessages(t *testing.T) {
+ err := Struct(sample{})
+ app, ok := errs.AsAppError(err)
+ if !ok {
+ t.Fatalf("type %T", err)
+ }
+ if app.Code != errs.ErrInvalidArgument.Code {
+ t.Fatalf("code %s", app.Code)
+ }
+ if !strings.Contains(app.Msg, "请填写手机号") || !strings.Contains(app.Msg, "密码不能为空") || !strings.Contains(app.Msg, "设备ID不能为空") {
+ t.Fatalf("msg %s", app.Msg)
+ }
+ err = Struct(sample{Phone: "1", Password: "123", DeviceID: 1})
+ app, ok = errs.AsAppError(err)
+ if !ok || !strings.Contains(app.Msg, "密码不能少于6") {
+ t.Fatalf("min %v", err)
+ }
+ if err := Struct(sample{Phone: "1", Password: "123456", DeviceID: 1}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestStructLangEnglish(t *testing.T) {
+ err := StructLang(i18n.En, sample{})
+ app, ok := errs.AsAppError(err)
+ if !ok || !strings.Contains(app.Msg, "Please enter your phone number") || !strings.Contains(app.Msg, "Password is required") {
+ t.Fatalf("en %v", err)
+ }
+ err = StructLang(i18n.ZhTW, sample{Phone: "1", Password: "123", DeviceID: 1})
+ app, ok = errs.AsAppError(err)
+ if !ok || !strings.Contains(app.Msg, "密碼不能少於6") {
+ t.Fatalf("zh-TW %v", err)
+ }
+}
diff --git a/internal/transport/http/auth.go b/internal/transport/http/auth.go
new file mode 100644
index 0000000..e609b2c
--- /dev/null
+++ b/internal/transport/http/auth.go
@@ -0,0 +1,115 @@
+package httpx
+
+import (
+ "strconv"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+
+ authlogic "spark-server/internal/module/auth/logic"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/ratelimit"
+ "spark-server/internal/pkg/reqctx"
+)
+
+const (
+ headerAuthorization = "Authorization"
+ headerDeviceID = "X-Device-ID"
+ headerAppVersion = "X-App-Version"
+ headerLanguage = "Accept-Language"
+ headerXLanguage = "X-Language"
+ headerRealIP = "X-Real-IP"
+ headerForwardedFor = "X-Forwarded-For"
+ bearerPrefix = "bearer "
+)
+
+func Auth(svc authlogic.Service) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if svc == nil || publicPath(c.Request.URL.Path) {
+ c.Next()
+ return
+ }
+ token, ok := bearerToken(c.GetHeader(headerAuthorization))
+ if !ok {
+ abortAuth(c, errs.ErrUnauthorized)
+ return
+ }
+ principal, err := svc.Authenticate(c.Request.Context(), token)
+ if err != nil {
+ abortAuth(c, err)
+ return
+ }
+ if err := matchDeviceHeader(c.GetHeader(headerDeviceID), principal.DeviceID); err != nil {
+ abortAuth(c, err)
+ return
+ }
+ rc := reqctx.From(c.Request.Context())
+ if rc == nil {
+ rc = &reqctx.RequestContext{}
+ c.Request = c.Request.WithContext(reqctx.With(c.Request.Context(), rc))
+ }
+ rc.UserID = principal.UserID
+ rc.DeviceID = principal.DeviceID
+ rc.JTI = principal.JTI
+ rc.TokenExpires = principal.ExpiresAt
+ c.Next()
+ }
+}
+
+func AuthRateLimit(l *ratelimit.Limiter) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if l == nil || !authLimitPath(c.Request.URL.Path) {
+ c.Next()
+ return
+ }
+ ip := reqctx.RealIP(c.Request.Context())
+ if !l.Allow(ip) {
+ c.Abort()
+ JSONError(c, errs.ErrRateLimited)
+ return
+ }
+ c.Next()
+ }
+}
+
+func authLimitPath(path string) bool {
+ switch path {
+ case "/v1/auth/login", "/v1/auth/email-codes", "/v1/auth/token/refresh":
+ return true
+ default:
+ return false
+ }
+}
+
+func publicPath(path string) bool {
+ switch path {
+ case "/healthz", "/readyz", "/openapi.yaml", "/docs", "/docs/", "/v1/auth/login", "/v1/auth/email-codes", "/v1/auth/token/refresh":
+ return true
+ default:
+ return strings.HasPrefix(path, "/docs/")
+ }
+}
+
+func bearerToken(h string) (string, bool) {
+ if len(h) < len(bearerPrefix) || !strings.EqualFold(h[:len(bearerPrefix)], bearerPrefix) {
+ return "", false
+ }
+ tok := strings.TrimSpace(h[len(bearerPrefix):])
+ return tok, tok != ""
+}
+
+func matchDeviceHeader(raw string, want uint64) error {
+ if raw == "" {
+ return nil
+ }
+ got, err := strconv.ParseUint(raw, 10, 64)
+ if err != nil || got != want {
+ return errs.ErrUnauthorized
+ }
+ return nil
+}
+
+func abortAuth(c *gin.Context, err error) {
+ c.Abort()
+ JSONError(c, err)
+}
diff --git a/internal/transport/http/docs.go b/internal/transport/http/docs.go
new file mode 100644
index 0000000..714abba
--- /dev/null
+++ b/internal/transport/http/docs.go
@@ -0,0 +1,19 @@
+package httpx
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+func registerDocs(r *gin.Engine) {
+ r.GET("/openapi.yaml", func(c *gin.Context) {
+ c.Data(http.StatusOK, "application/yaml; charset=utf-8", openAPISpec)
+ })
+ r.GET("/docs", func(c *gin.Context) {
+ c.Data(http.StatusOK, "text/html; charset=utf-8", docsHTML)
+ })
+ r.GET("/docs/", func(c *gin.Context) {
+ c.Redirect(http.StatusFound, "/docs")
+ })
+}
diff --git a/internal/transport/http/docs/index.html b/internal/transport/http/docs/index.html
new file mode 100644
index 0000000..69b057f
--- /dev/null
+++ b/internal/transport/http/docs/index.html
@@ -0,0 +1,26 @@
+
+
+
+
+ Spark IM API
+
+
+
+
+
+
+
+
+
+
diff --git a/internal/transport/http/docs/openapi.yaml b/internal/transport/http/docs/openapi.yaml
new file mode 100644
index 0000000..cac2446
--- /dev/null
+++ b/internal/transport/http/docs/openapi.yaml
@@ -0,0 +1,1154 @@
+openapi: 3.0.3
+info:
+ title: Spark IM API
+ version: 0.1.0
+ description: |
+ 本页是 **HTTP REST**(Gin `127.0.0.1:8080`)。长连接 IM 是另一套 TCP,见文末。
+
+ ## 响应
+
+ ```json
+ {"code":"0","msg":"ok","data":{}}
+ ```
+
+ `code` 是字符串。`"0"` 成功,其它失败。失败时 `data` 为 `{}`。看 `code`,不要只看 HTTP 状态(多数接口 HTTP 200;`/readyz` 未就绪才是 503)。
+
+ ## 鉴权
+
+ 1. `POST /v1/auth/login` 拿 `access_token` / `refresh_token`。测试号 `test01@spark.com`~`test10@spark.com`,`verify_code` 任意。
+ 2. 其它接口:`Authorization: Bearer `。
+ 3. 可选 `X-Device-ID` 必须等于登录时的 `device_id`,否则 `40101`。
+ 4. 续期 `POST /v1/auth/token/refresh`(用 refresh,不必带 Bearer)。退出 `POST /v1/auth/logout`。
+
+ 语言:`X-Language` 优先,否则 `Accept-Language`。取值 `zh-CN`(默认)/ `en` / `zh-TW`。
+
+ ## TCP(不是 Protobuf)
+
+ 二进制 Frame 头 23 字节(Magic `SP` + Command + SequenceID + BodyLength),**Body 是上面同一套 JSON**。没有 `.proto`,不要按 Protobuf 解包。
+ 公网 TLS 在 Nginx `:443` 终结;gnet 只收本机 PROXY 明文。心跳 / AUTH / GENERATE_MSG_ID 已通;发消息与同步还在接。
+servers:
+ - url: http://127.0.0.1:8080
+ description: loopback HTTP (not public)
+security:
+ - bearerAuth: []
+tags:
+ - name: system
+ - name: auth
+ - name: users
+ - name: friends
+ - name: groups
+ - name: conversations
+ - name: messages
+ - name: files
+ - name: favorites
+paths:
+ /healthz:
+ get:
+ tags: [system]
+ security: []
+ summary: 进程探活
+ operationId: healthz
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ example:
+ code: "0"
+ msg: ok
+ data:
+ status: ok
+ /readyz:
+ get:
+ tags: [system]
+ security: []
+ summary: 就绪探活(MySQL / Redis / 是否接流量)
+ description: |
+ 进程存活用 `/healthz`(始终 200)。
+ `/readyz` 在未接流量、依赖不可用或关停中返回 HTTP 503,`code=50301`。
+ Nginx / 编排应摘流看本接口,不要看 `/healthz`。
+ operationId: readyz
+ responses:
+ "200":
+ description: 已就绪
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ example:
+ code: "0"
+ msg: ok
+ data:
+ status: ok
+ accepting: true
+ mysql: ok
+ redis: ok
+ taskpool: ok
+ "503":
+ description: 未就绪
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ example:
+ code: "50301"
+ msg: 服务不可用
+ data:
+ status: not_ready
+ accepting: false
+ mysql: skipped
+ redis: skipped
+ taskpool: ok
+ /docs:
+ get:
+ tags: [system]
+ security: []
+ summary: OpenAPI 文档页(Swagger UI)
+ operationId: docsUI
+ responses:
+ "200":
+ description: HTML
+ content:
+ text/html:
+ schema:
+ type: string
+ /openapi.yaml:
+ get:
+ tags: [system]
+ security: []
+ summary: OpenAPI 规范原文
+ operationId: openAPISpec
+ responses:
+ "200":
+ description: YAML
+ content:
+ application/yaml:
+ schema:
+ type: string
+ /v1/auth/email-codes:
+ post:
+ tags: [auth]
+ security: []
+ summary: 发送邮箱登录验证码
+ description: |
+ 验证码 5 分钟有效,同一邮箱 60 秒内只能发一次。
+ 测试账号 `test01@spark.com`~`test10@spark.com` 无需发码,登录时任意填写 `verify_code`。
+ 当前不发送真实邮件,验证码写入 Redis(无 Redis 时为进程内存)。
+ operationId: sendEmailCode
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/SendEmailCodeRequest"
+ example:
+ email: ada@example.com
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ examples:
+ ok:
+ value:
+ code: "0"
+ msg: ok
+ data:
+ expires_in: 300
+ /v1/auth/login:
+ post:
+ tags: [auth]
+ security: []
+ summary: 邮箱验证码登录并签发 JWT
+ description: |
+ 首次验证成功会创建用户(Snowflake uid、默认 spark_id、shortid 个人二维码票据,不含 uid)。
+ 登录写入/更新 `user_devices`(device_type 存在 `platform` 列、device_name、app_version、language、last_ip),并追加 `user_login_logs`。
+ 返回 access_token 与 refresh_token。测试账号任意验证码即可。
+ `device_type` 必填:ios / android / harmony / windows / macos / web(也接受旧字段 `platform`)。
+ operationId: login
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LoginRequest"
+ example:
+ email: test01@spark.com
+ verify_code: "000000"
+ device_id: 7
+ device_type: ios
+ device_name: iPhone
+ responses:
+ "200":
+ description: 统一 envelope。成功时 data 为 LoginData;失败时 data 为 {}
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LoginEnvelope"
+ examples:
+ ok:
+ value:
+ code: "0"
+ msg: ok
+ data:
+ access_token: eyJhbGciOiJIUzI1NiJ9...
+ refresh_token: eyJhbGciOiJIUzI1NiJ9...
+ expires_in: 604800
+ refresh_expires_in: 2592000
+ user_id: 1940000000000000000
+ device_id: 7
+ spark_id: s1a2b3c
+ unauthorized:
+ value:
+ code: "40101"
+ msg: 未授权
+ data: {}
+ invalid:
+ value:
+ code: "40001"
+ msg: 请填写验证码
+ data: {}
+ /v1/auth/token/refresh:
+ post:
+ tags: [auth]
+ security: []
+ summary: 用 refresh_token 续期
+ description: |
+ 校验 refresh_token 后签发新的 access/refresh,旧一对立即失效(jti denylist)。
+ 不需要 access Bearer。按 RealIP 限流。
+ operationId: refreshToken
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/RefreshRequest"
+ example:
+ refresh_token: eyJhbGciOiJIUzI1NiJ9...
+ responses:
+ "200":
+ description: 与登录相同的 LoginData
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/LoginEnvelope"
+ /v1/auth/logout:
+ post:
+ tags: [auth]
+ summary: 退出当前设备
+ description: 吊销该设备的 access/refresh,并踢掉同 device_id 的 TCP 连接。
+ operationId: logout
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ /v1/users/me:
+ get:
+ tags: [users]
+ summary: 当前用户资料
+ operationId: getMe
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ example:
+ code: "0"
+ msg: ok
+ data:
+ uid: 1940000000000000000
+ spark_id: alice_ok
+ nickname: test01
+ avatar: perm/avatar/1940000000000000000/20260917/1940000000000000001.png
+ qrcode: "-NDveu-9Q"
+ birthday: ""
+ gender: 0
+ status: 1
+ last_login_time: 1726550000000
+ last_active_time: 1726550000000
+ create_time: 1726550000000
+ update_time: 1726550000000
+ patch:
+ tags: [users]
+ summary: 修改资料
+ description: 可改 spark_id、nickname、avatar、gender(0/1/2)、birthday(YYYY-MM-DD)。未出现的字段不改。
+ operationId: patchMe
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PatchMeRequest"
+ example:
+ nickname: Ada
+ gender: 2
+ birthday: "1994-05-01"
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ /v1/friends/lookup:
+ get:
+ tags: [friends]
+ summary: 按邮箱 / Spark账号 / 二维码查找用户
+ description: |
+ 查询参数 `email`、`spark_id`、`qrcode`、`uid` 四选一。
+ 二维码为 shortid 个人扫描票据,不含 uid。对方已拉黑当前用户时返回 40401。
+ operationId: lookupFriend
+ parameters:
+ - in: query
+ name: email
+ schema: { type: string, format: email }
+ - in: query
+ name: spark_id
+ schema: { type: string }
+ - in: query
+ name: qrcode
+ schema: { type: string, example: "-NDveu-9Q" }
+ - in: query
+ name: uid
+ schema: { type: integer, format: int64 }
+ responses:
+ "200":
+ description: 统一 envelope,data 为好友卡片(含 relation:0无 1好友 2已拉黑)
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ /v1/friends/requests:
+ get:
+ tags: [friends]
+ summary: 收到的待处理好友申请
+ operationId: listFriendRequests
+ parameters:
+ - in: query
+ name: limit
+ schema: { type: integer, default: 20, maximum: 100 }
+ responses:
+ "200":
+ description: data.items
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ post:
+ tags: [friends]
+ summary: 发起好友申请
+ description: |
+ 通过扫描二维码、搜索邮箱或 spark_id 发起。可填申请理由与备注。
+ 同一对用户只保留一条申请,重复发起覆盖理由/备注,不新增行。
+ 若对方已有待处理申请指向自己,则自动成为双向好友并写入一条系统消息。
+ 不支持单向好友。
+ operationId: applyFriend
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/FriendApplyRequest"
+ example:
+ email: test02@spark.com
+ reason: 我是同事
+ remark: 同事
+ responses:
+ "200":
+ description: data.status 为 pending 或 accepted
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ /v1/friends:
+ get:
+ tags: [friends]
+ summary: 好友列表
+ operationId: listFriends
+ parameters:
+ - in: query
+ name: limit
+ schema: { type: integer, default: 20, maximum: 100 }
+ responses:
+ "200":
+ description: data.items
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ post:
+ tags: [friends]
+ summary: 同意好友申请
+ description: 成为双向好友后自动在单聊中插入一条 `kind=friend.became` 系统消息并入队 deliver。
+ operationId: acceptFriend
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/FriendAcceptRequest"
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ /v1/friends/{uid}:
+ get:
+ tags: [friends]
+ summary: 查看好友资料
+ operationId: getFriend
+ parameters:
+ - $ref: "#/components/parameters/FriendUID"
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ delete:
+ tags: [friends]
+ summary: 删除好友
+ description: 解除双向关系,双方都不再是好友。
+ operationId: deleteFriend
+ parameters:
+ - $ref: "#/components/parameters/FriendUID"
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ /v1/friends/{uid}/remark:
+ put:
+ tags: [friends]
+ summary: 设置好友备注
+ operationId: setFriendRemark
+ parameters:
+ - $ref: "#/components/parameters/FriendUID"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/FriendRemarkRequest"
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ /v1/friends/{uid}/block:
+ put:
+ tags: [friends]
+ summary: 拉黑
+ description: 单向拉黑并解除好友关系。被拉黑一方再申请时视为用户不存在。
+ operationId: blockFriend
+ parameters:
+ - $ref: "#/components/parameters/FriendUID"
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ delete:
+ tags: [friends]
+ summary: 取消拉黑
+ operationId: unblockFriend
+ parameters:
+ - $ref: "#/components/parameters/FriendUID"
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ /v1/friends/{uid}/reports:
+ post:
+ tags: [friends]
+ summary: 投诉好友
+ operationId: reportFriend
+ parameters:
+ - $ref: "#/components/parameters/FriendUID"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/FriendReportRequest"
+ example:
+ reason: 1
+ detail: spam
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ /v1/groups:
+ get:
+ tags: [groups]
+ summary: 我加入的群
+ operationId: listGroups
+ parameters:
+ - in: query
+ name: limit
+ schema: { type: integer, default: 20, maximum: 100 }
+ responses:
+ "200":
+ description: data.items,id 为自增群号,conversation_id 为 Snowflake 会话 id
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ post:
+ tags: [groups]
+ summary: 创建群
+ description: |
+ `id` 为表内自增群号。发消息、同步使用返回的 `conversation_id`(Snowflake)。
+ 只能拉好友入群。群主无法直接退群,需先转让。
+ 建群成功后服务端按实际人数拼接成员已有头像(注册默认或自定义),最多 9 人;两人就是并排两格,不会凑九宫格。
+ 成员头像拉不到时用字母占位图,整张拼图失败则改用群名默认头像,`avatar` 不会为空。
+ 写入 `perm/group/{群号}/`。之后可用 `purpose=group_avatar` 直传自定义头像再 PATCH。
+ operationId: createGroup
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CreateGroupRequest"
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ /v1/groups/{id}:
+ get:
+ tags: [groups]
+ summary: 群资料
+ operationId: getGroup
+ parameters:
+ - $ref: "#/components/parameters/GroupID"
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ patch:
+ tags: [groups]
+ summary: 修改群名称 / 头像 / 全员禁言
+ operationId: updateGroup
+ parameters:
+ - $ref: "#/components/parameters/GroupID"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UpdateGroupRequest"
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ delete:
+ tags: [groups]
+ summary: 解散群(仅群主)
+ operationId: dissolveGroup
+ parameters:
+ - $ref: "#/components/parameters/GroupID"
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ /v1/groups/{id}/members:
+ get:
+ tags: [groups]
+ summary: 群成员
+ operationId: listGroupMembers
+ parameters:
+ - $ref: "#/components/parameters/GroupID"
+ - in: query
+ name: limit
+ schema: { type: integer, default: 200, maximum: 200 }
+ responses:
+ "200":
+ description: data.items
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ post:
+ tags: [groups]
+ summary: 邀请好友入群
+ operationId: inviteGroupMembers
+ parameters:
+ - $ref: "#/components/parameters/GroupID"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/InviteGroupMembersRequest"
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ /v1/groups/{id}/members/{uid}:
+ patch:
+ tags: [groups]
+ summary: 改成员角色 / 禁言 / 群昵称
+ operationId: updateGroupMember
+ parameters:
+ - $ref: "#/components/parameters/GroupID"
+ - $ref: "#/components/parameters/FriendUID"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UpdateGroupMemberRequest"
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ delete:
+ tags: [groups]
+ summary: 踢人,或 uid 为自己时退群
+ operationId: removeGroupMember
+ parameters:
+ - $ref: "#/components/parameters/GroupID"
+ - $ref: "#/components/parameters/FriendUID"
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ /v1/groups/{id}/transfer:
+ post:
+ tags: [groups]
+ summary: 转让群主
+ operationId: transferGroup
+ parameters:
+ - $ref: "#/components/parameters/GroupID"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TransferGroupRequest"
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ /v1/files:
+ post:
+ tags: [files]
+ summary: 签发对象存储直传凭证
+ description: |
+ 客户端只传 `purpose` / `mime` / `size`,服务端生成 object_key 并签发短时 Presigned PUT。
+ 文件直传 RustFS,不经过 Gin。
+ `avatar` 写入 `perm/avatar/{uid}/…`(不过期);`group_avatar` 写入 `perm/group/{群号}/…`(需 `group_id`,群主/管理员);
+ 聊天媒体写入 `chat/{yyyyMMdd}/{uid}/…`(桶生命周期过期)。
+ 客户端 PUT 时必须带返回的 `headers`(至少 `Content-Type`)。消息与头像只存 `object_key`。
+ operationId: createUploadCredential
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CreateUploadRequest"
+ example:
+ purpose: chat_image
+ mime: image/jpeg
+ size: 245760
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ example:
+ code: "0"
+ msg: ok
+ data:
+ object_key: chat/20260917/1/2.jpg
+ upload_url: http://47.236.66.9:9000/spark/chat/20260917/1/2.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256
+ method: PUT
+ headers:
+ Content-Type: image/jpeg
+ expires_in: 900
+ /v1/files/downloads:
+ post:
+ tags: [files]
+ summary: 签发对象存储下载凭证
+ description: 已登录用户用 object_key 换取短时 Presigned GET。key 必须是 `perm/` 或 `chat/` 前缀。
+ operationId: createDownloadCredential
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CreateDownloadRequest"
+ example:
+ object_key: chat/20260917/1/2.jpg
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ /v1/favorites:
+ get:
+ tags: [favorites]
+ summary: 收藏列表
+ operationId: listFavorites
+ parameters:
+ - in: query
+ name: limit
+ schema:
+ type: integer
+ default: 20
+ maximum: 100
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ post:
+ tags: [favorites]
+ summary: 收藏一条消息
+ description: |
+ 会话成员可将消息收入收藏。图片/语音/视频/文件会 CopyObject 到 `perm/fav/{uid}/…`。
+ 源对象已从 `chat/` 过期删除则失败(`code=40402`),不提供本地补传。
+ 同一 `msg_id` 重复收藏返回已有记录。撤回不影响已收藏快照。
+ operationId: createFavorite
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CreateFavoriteRequest"
+ example:
+ msg_id: 1
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+ /v1/favorites/{id}:
+ delete:
+ tags: [favorites]
+ summary: 取消收藏
+ operationId: deleteFavorite
+ parameters:
+ - $ref: "#/components/parameters/FavoriteID"
+ responses:
+ "200":
+ description: 统一 envelope
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Envelope"
+components:
+ securitySchemes:
+ bearerAuth:
+ type: http
+ scheme: bearer
+ bearerFormat: JWT
+ description: Access JWT from /v1/auth/login. Same token is used for TCP AUTH.
+ schemas:
+ Envelope:
+ type: object
+ required: [code, msg, data]
+ properties:
+ code:
+ type: string
+ description: 业务码,成功为 "0"
+ example: "0"
+ msg:
+ type: string
+ example: ok
+ data:
+ type: object
+ additionalProperties: true
+ SendEmailCodeRequest:
+ type: object
+ required: [email]
+ properties:
+ email:
+ type: string
+ format: email
+ LoginRequest:
+ type: object
+ required: [email, verify_code, device_id]
+ properties:
+ email:
+ type: string
+ format: email
+ verify_code:
+ type: string
+ description: 邮箱验证码。测试账号 test01@spark.com~test10@spark.com 可任意填写。
+ device_id:
+ type: integer
+ format: int64
+ minimum: 1
+ device_type:
+ type: string
+ enum: [ios, android, harmony, windows, macos, web]
+ platform:
+ type: string
+ description: device_type 的兼容字段
+ enum: [ios, android, harmony, windows, macos, web]
+ device_name:
+ type: string
+ maxLength: 128
+ LoginData:
+ type: object
+ required: [access_token, refresh_token, expires_in, refresh_expires_in, user_id, device_id, spark_id]
+ properties:
+ access_token:
+ type: string
+ refresh_token:
+ type: string
+ expires_in:
+ type: integer
+ format: int64
+ description: access TTL in seconds (default 604800 = 7 days)
+ example: 604800
+ refresh_expires_in:
+ type: integer
+ format: int64
+ description: refresh TTL in seconds (default 2592000 = 30 days)
+ example: 2592000
+ user_id:
+ type: integer
+ format: int64
+ description: Snowflake user id
+ device_id:
+ type: integer
+ format: int64
+ spark_id:
+ type: string
+ description: Public handle, unique and user-changeable
+ RefreshRequest:
+ type: object
+ required: [refresh_token]
+ properties:
+ refresh_token:
+ type: string
+ PatchMeRequest:
+ type: object
+ properties:
+ spark_id:
+ type: string
+ minLength: 6
+ maxLength: 32
+ pattern: "^[a-zA-Z][a-zA-Z0-9_]{5,31}$"
+ nickname:
+ type: string
+ minLength: 1
+ maxLength: 64
+ avatar:
+ type: string
+ maxLength: 512
+ gender:
+ type: integer
+ enum: [0, 1, 2]
+ birthday:
+ type: string
+ description: YYYY-MM-DD;空字符串表示清除
+ example: "1994-05-01"
+ FriendApplyRequest:
+ type: object
+ properties:
+ uid:
+ type: integer
+ format: int64
+ email:
+ type: string
+ format: email
+ spark_id:
+ type: string
+ qrcode:
+ type: string
+ description: 个人二维码扫描票据(shortid),不含 uid
+ reason:
+ type: string
+ maxLength: 128
+ remark:
+ type: string
+ maxLength: 64
+ FriendAcceptRequest:
+ type: object
+ required: [request_id]
+ properties:
+ request_id:
+ type: integer
+ format: int64
+ remark:
+ type: string
+ maxLength: 64
+ FriendRemarkRequest:
+ type: object
+ properties:
+ remark:
+ type: string
+ maxLength: 64
+ FriendReportRequest:
+ type: object
+ required: [reason]
+ properties:
+ reason:
+ type: integer
+ enum: [1, 2, 3, 4]
+ description: 1=垃圾 2=骚扰 3=不实身份 4=其他
+ detail:
+ type: string
+ maxLength: 512
+ CreateUploadRequest:
+ type: object
+ required: [purpose, mime, size]
+ properties:
+ purpose:
+ type: string
+ enum: [avatar, group_avatar, chat_image, chat_voice, chat_video, chat_gif, chat_file]
+ mime:
+ type: string
+ example: image/jpeg
+ size:
+ type: integer
+ format: int64
+ minimum: 1
+ group_id:
+ type: integer
+ format: int64
+ minimum: 1
+ description: purpose=group_avatar 时必填,为自增群号
+ CreateDownloadRequest:
+ type: object
+ required: [object_key]
+ properties:
+ object_key:
+ type: string
+ maxLength: 512
+ CreateFavoriteRequest:
+ type: object
+ required: [msg_id]
+ properties:
+ msg_id:
+ type: integer
+ format: int64
+ minimum: 1
+ CreateGroupRequest:
+ type: object
+ required: [name, member_uids]
+ properties:
+ name:
+ type: string
+ minLength: 1
+ maxLength: 32
+ member_uids:
+ type: array
+ minItems: 1
+ maxItems: 199
+ items:
+ type: integer
+ format: int64
+ UpdateGroupRequest:
+ type: object
+ properties:
+ name:
+ type: string
+ minLength: 1
+ maxLength: 32
+ avatar:
+ type: string
+ muted_all:
+ type: boolean
+ InviteGroupMembersRequest:
+ type: object
+ required: [uids]
+ properties:
+ uids:
+ type: array
+ minItems: 1
+ items:
+ type: integer
+ format: int64
+ UpdateGroupMemberRequest:
+ type: object
+ properties:
+ role:
+ type: integer
+ enum: [2, 3]
+ description: 2=管理员 3=普通成员;群主只能通过转让变更
+ speak_muted:
+ type: boolean
+ nickname:
+ type: string
+ maxLength: 32
+ TransferGroupRequest:
+ type: object
+ required: [uid]
+ properties:
+ uid:
+ type: integer
+ format: int64
+ LoginEnvelope:
+ type: object
+ required: [code, msg, data]
+ properties:
+ code:
+ type: string
+ msg:
+ type: string
+ data:
+ $ref: "#/components/schemas/LoginData"
+ AuthData:
+ type: object
+ properties:
+ user_id:
+ type: integer
+ format: int64
+ device_id:
+ type: integer
+ format: int64
+ MsgIDData:
+ type: object
+ properties:
+ msg_id:
+ type: integer
+ format: int64
+ headers:
+ X-Request-ID:
+ schema:
+ type: string
+ X-Trace-ID:
+ schema:
+ type: string
+ X-Device-ID:
+ schema:
+ type: string
+ description: Optional. Must match JWT did when present.
+ X-App-Version:
+ schema:
+ type: string
+ Accept-Language:
+ schema:
+ type: string
+ enum: [zh-CN, en, zh-TW]
+ example: zh-CN
+ description: 用户语言。也可用 X-Language。缺省 zh-CN。
+ X-Language:
+ schema:
+ type: string
+ enum: [zh-CN, en, zh-TW]
+ description: 覆盖 Accept-Language 的简洁语言标签。
+ X-Real-IP:
+ schema:
+ type: string
+ description: Nginx PROXY / SNI 写入的客户端真实 IP,进入 reqctx.RealIP。
+ parameters:
+ Authorization:
+ in: header
+ name: Authorization
+ required: true
+ schema:
+ type: string
+ description: "Bearer "
+ X-Device-ID:
+ in: header
+ name: X-Device-ID
+ required: false
+ schema:
+ type: string
+ X-App-Version:
+ in: header
+ name: X-App-Version
+ required: false
+ schema:
+ type: string
+ Accept-Language:
+ in: header
+ name: Accept-Language
+ required: false
+ schema:
+ type: string
+ enum: [zh-CN, en, zh-TW]
+ description: 用户语言。缺省 zh-CN。
+ X-Language:
+ in: header
+ name: X-Language
+ required: false
+ schema:
+ type: string
+ enum: [zh-CN, en, zh-TW]
+ description: 覆盖 Accept-Language。
+ FriendUID:
+ in: path
+ name: uid
+ required: true
+ schema:
+ type: integer
+ format: int64
+ description: 对方 Snowflake uid。
+ FavoriteID:
+ in: path
+ name: id
+ required: true
+ schema:
+ type: integer
+ format: int64
+ description: 收藏 Snowflake id。
+ GroupID:
+ in: path
+ name: id
+ required: true
+ schema:
+ type: integer
+ format: int64
+ description: 自增群号(不是 conversation_id)。
diff --git a/internal/transport/http/embed.go b/internal/transport/http/embed.go
new file mode 100644
index 0000000..025d5a4
--- /dev/null
+++ b/internal/transport/http/embed.go
@@ -0,0 +1,11 @@
+package httpx
+
+import (
+ _ "embed"
+)
+
+//go:embed docs/openapi.yaml
+var openAPISpec []byte
+
+//go:embed docs/index.html
+var docsHTML []byte
diff --git a/internal/transport/http/file_test.go b/internal/transport/http/file_test.go
new file mode 100644
index 0000000..ff79f88
--- /dev/null
+++ b/internal/transport/http/file_test.go
@@ -0,0 +1,115 @@
+package httpx
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "go.uber.org/zap"
+
+ "spark-server/internal/infrastructure/id"
+ authlogic "spark-server/internal/module/auth/logic"
+ convlogic "spark-server/internal/module/conversation/logic"
+ convmodel "spark-server/internal/module/conversation/model"
+ favlogic "spark-server/internal/module/favorite/logic"
+ favmodel "spark-server/internal/module/favorite/model"
+ msgentity "spark-server/internal/module/message/entity"
+ 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/errs"
+)
+
+type stubPresign struct{}
+
+func (stubPresign) PresignPut(_ context.Context, key, _ string, _ time.Duration) (string, error) {
+ return "http://rustfs.test/" + key + "?sig=1", nil
+}
+
+func (stubPresign) PresignGet(_ context.Context, key string, _ time.Duration) (string, error) {
+ return "http://rustfs.test/" + key + "?get=1", nil
+}
+
+func testFileEngine(t *testing.T) (*ginEngineBundle, *id.Generator, *msgmodel.MemoryRepo, *convmodel.MemoryP2P) {
+ t.Helper()
+ j, err := authlogic.NewJWT(&authlogic.Options{Secret: "test-secret-test-secret", Issuer: "spark-im"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ ids, err := id.New(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil)
+ msgs := msgmodel.NewMemoryRepo()
+ convs := convmodel.NewMemoryP2P()
+ files := uploadlogic.NewService(stubPresign{}, ids, uploadlogic.Options{
+ Now: func() time.Time { return time.Date(2026, 9, 17, 0, 0, 0, 0, time.UTC) },
+ })
+ favs := favlogic.NewService(favmodel.NewMemoryRepo(), msgs, convs, nil, ids)
+ r := NewEngine(zap.NewNop(), &EngineDeps{
+ Auth: j, Login: authlogic.NewLoginService(j, users), Users: users,
+ Files: files, Favorites: favs,
+ })
+ return &ginEngineBundle{engine: r}, ids, msgs, convs
+}
+
+func TestUploadCredentialHTTP(t *testing.T) {
+ bundle, _, _, _ := testFileEngine(t)
+ h := bundle.engine
+ tok := loginUser(t, h, "test01@spark.com", 1)
+ env := authJSON(t, h, &tok, http.MethodPost, "/v1/files", `{"purpose":"chat_image","mime":"image/jpeg","size":1024}`)
+ if env.Code != "0" {
+ t.Fatalf("%+v", env)
+ }
+ var cred struct {
+ ObjectKey string `json:"object_key"`
+ Method string `json:"method"`
+ }
+ if err := json.Unmarshal(env.Data, &cred); err != nil || cred.Method != "PUT" || !strings.Contains(cred.ObjectKey, "chat/20260917/") {
+ t.Fatalf("%s %v", env.Data, err)
+ }
+ bad := authJSON(t, h, &tok, http.MethodPost, "/v1/files", `{"purpose":"avatar","mime":"application/pdf","size":10}`)
+ if bad.Code != errs.ErrInvalidArgument.Code {
+ t.Fatalf("mime %+v", bad)
+ }
+}
+
+func TestFavoriteTextHTTP(t *testing.T) {
+ bundle, ids, msgs, convs := testFileEngine(t)
+ h := bundle.engine
+ tok := loginUser(t, h, "test01@spark.com", 1)
+ p2p := convlogic.NewP2PService(convs, ids)
+ convID, err := p2p.EnsureP2P(t.Context(), tok.UserID, tok.UserID+1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, err := msgentity.NewText("keep").Marshal()
+ if err != nil {
+ t.Fatal(err)
+ }
+ msgID, err := ids.NextID()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := msgs.Create(t.Context(), &msgentity.Message{
+ ID: msgID, ConversationID: convID, SenderID: tok.UserID, ClientMsgID: "http-1",
+ Type: msgentity.TypeText, Content: raw, Status: msgentity.StatusNormal,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ body := `{"msg_id":` + strconv.FormatInt(msgID, 10) + `}`
+ env := authJSON(t, h, &tok, http.MethodPost, "/v1/favorites", body)
+ if env.Code != "0" {
+ t.Fatalf("fav %+v", env)
+ }
+ list := authJSON(t, h, &tok, http.MethodGet, "/v1/favorites", "")
+ if list.Code != "0" || !strings.Contains(string(list.Data), `"keep"`) {
+ t.Fatalf("list %+v", list)
+ }
+}
diff --git a/internal/transport/http/friend_test.go b/internal/transport/http/friend_test.go
new file mode 100644
index 0000000..b2ef2e2
--- /dev/null
+++ b/internal/transport/http/friend_test.go
@@ -0,0 +1,136 @@
+package httpx
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "go.uber.org/zap"
+
+ "spark-server/internal/infrastructure/id"
+ authentity "spark-server/internal/module/auth/entity"
+ authlogic "spark-server/internal/module/auth/logic"
+ convlogic "spark-server/internal/module/conversation/logic"
+ convmodel "spark-server/internal/module/conversation/model"
+ delivermodel "spark-server/internal/module/deliver/model"
+ friendlogic "spark-server/internal/module/friend/logic"
+ friendmodel "spark-server/internal/module/friend/model"
+ msgmodel "spark-server/internal/module/message/model"
+ userlogic "spark-server/internal/module/user/logic"
+ usermodel "spark-server/internal/module/user/model"
+ "spark-server/internal/pkg/reply"
+)
+
+func testFriendEngine(t *testing.T) *ginEngineBundle {
+ t.Helper()
+ j, err := authlogic.NewJWT(&authlogic.Options{Secret: "test-secret-test-secret", Issuer: "spark-im"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ ids, err := id.New(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil)
+ chats := convlogic.NewP2PService(convmodel.NewMemoryP2P(), ids)
+ friends := friendlogic.NewService(
+ friendmodel.NewMemoryRepo(), users, ids,
+ friendlogic.NewGreeter(chats, msgmodel.NewMemoryRepo(), ids, delivermodel.NewMemoryQueue()),
+ )
+ r := NewEngine(zap.NewNop(), &EngineDeps{
+ Auth: j,
+ Login: authlogic.NewLoginService(j, users),
+ Users: users,
+ Friends: friends,
+ })
+ return &ginEngineBundle{engine: r}
+}
+
+type ginEngineBundle struct {
+ engine http.Handler
+}
+
+func loginUser(t *testing.T, h http.Handler, email string, device uint64) authentity.IssuedToken {
+ t.Helper()
+ body := fmt.Sprintf(`{"email":%q,"verify_code":"any","device_id":%d,"device_type":"ios","device_name":"phone"}`, email, device)
+ req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, req)
+ var env reply.Envelope
+ if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
+ t.Fatal(err)
+ }
+ if env.Code != "0" {
+ t.Fatalf("login %s %+v %s", email, env, w.Body.String())
+ }
+ var tok authentity.IssuedToken
+ if err := json.Unmarshal(env.Data, &tok); err != nil {
+ t.Fatal(err)
+ }
+ return tok
+}
+
+func authJSON(t *testing.T, h http.Handler, tok *authentity.IssuedToken, method, path, body string) reply.Envelope {
+ t.Helper()
+ var rdr *strings.Reader
+ if body != "" {
+ rdr = strings.NewReader(body)
+ } else {
+ rdr = strings.NewReader("")
+ }
+ req := httptest.NewRequest(method, path, rdr)
+ req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
+ if body != "" {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, req)
+ var env reply.Envelope
+ if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
+ t.Fatal(err)
+ }
+ return env
+}
+
+func TestFriendApplyAcceptHTTP(t *testing.T) {
+ bundle := testFriendEngine(t)
+ h := bundle.engine
+ a := loginUser(t, h, "test01@spark.com", 1)
+ b := loginUser(t, h, "test02@spark.com", 2)
+
+ apply := authJSON(t, h, &a, http.MethodPost, "/v1/friends/requests", `{"email":"test02@spark.com","reason":"hi","remark":"work"}`)
+ if apply.Code != "0" {
+ t.Fatalf("apply %+v", apply)
+ }
+ var applied friendlogic.ApplyResult
+ if err := json.Unmarshal(apply.Data, &applied); err != nil || applied.Status != "pending" || applied.RequestID == 0 {
+ t.Fatalf("apply data %+v %v", applied, err)
+ }
+ again := authJSON(t, h, &a, http.MethodPost, "/v1/friends/requests", `{"email":"test02@spark.com","reason":"again"}`)
+ var applied2 friendlogic.ApplyResult
+ _ = json.Unmarshal(again.Data, &applied2)
+ if applied2.RequestID != applied.RequestID {
+ t.Fatalf("dedupe %d %d", applied.RequestID, applied2.RequestID)
+ }
+
+ inbox := authJSON(t, h, &b, http.MethodGet, "/v1/friends/requests", "")
+ if inbox.Code != "0" || !strings.Contains(string(inbox.Data), `"reason":"again"`) {
+ t.Fatalf("inbox %+v", inbox)
+ }
+ acc := authJSON(t, h, &b, http.MethodPost, "/v1/friends", fmt.Sprintf(`{"request_id":%d,"remark":"ok"}`, applied.RequestID))
+ if acc.Code != "0" {
+ t.Fatalf("accept %+v", acc)
+ }
+ got := authJSON(t, h, &a, http.MethodGet, fmt.Sprintf("/v1/friends/%d", b.UserID), "")
+ if got.Code != "0" || !strings.Contains(string(got.Data), `"remark":"work"`) {
+ t.Fatalf("get %+v", got)
+ }
+ list := authJSON(t, h, &b, http.MethodGet, "/v1/friends", "")
+ if list.Code != "0" || !strings.Contains(string(list.Data), fmt.Sprintf(`"uid":%d`, a.UserID)) {
+ t.Fatalf("list %+v", list)
+ }
+}
diff --git a/internal/transport/http/group_test.go b/internal/transport/http/group_test.go
new file mode 100644
index 0000000..a13be05
--- /dev/null
+++ b/internal/transport/http/group_test.go
@@ -0,0 +1,85 @@
+package httpx
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "go.uber.org/zap"
+
+ "spark-server/internal/infrastructure/id"
+ authlogic "spark-server/internal/module/auth/logic"
+ convlogic "spark-server/internal/module/conversation/logic"
+ convmodel "spark-server/internal/module/conversation/model"
+ delivermodel "spark-server/internal/module/deliver/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"
+ userlogic "spark-server/internal/module/user/logic"
+ usermodel "spark-server/internal/module/user/model"
+)
+
+func testGroupEngine(t *testing.T) *ginEngineBundle {
+ t.Helper()
+ j, err := authlogic.NewJWT(&authlogic.Options{Secret: "test-secret-test-secret", Issuer: "spark-im"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ ids, err := id.New(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil)
+ friends := friendlogic.NewService(
+ friendmodel.NewMemoryRepo(), users, ids,
+ friendlogic.NewGreeter(convlogic.NewP2PService(convmodel.NewMemoryP2P(), ids), msgmodel.NewMemoryRepo(), ids, delivermodel.NewMemoryQueue()),
+ )
+ groups := grouplogic.NewService(groupmodel.NewMemoryRepo(), users, friends, ids, msgmodel.NewMemoryRepo(), delivermodel.NewMemoryQueue(), nil)
+ r := NewEngine(zap.NewNop(), &EngineDeps{
+ Auth: j,
+ Login: authlogic.NewLoginService(j, users),
+ Users: users,
+ Friends: friends,
+ Groups: groups,
+ })
+ return &ginEngineBundle{engine: r}
+}
+
+func TestGroupCreateHTTP(t *testing.T) {
+ h := testGroupEngine(t).engine
+ a := loginUser(t, h, "test01@spark.com", 1)
+ b := loginUser(t, h, "test02@spark.com", 2)
+
+ apply := authJSON(t, h, &a, http.MethodPost, "/v1/friends/requests", `{"email":"test02@spark.com"}`)
+ var applied friendlogic.ApplyResult
+ if err := json.Unmarshal(apply.Data, &applied); err != nil {
+ t.Fatal(err)
+ }
+ acc := authJSON(t, h, &b, http.MethodPost, "/v1/friends", fmt.Sprintf(`{"request_id":%d}`, applied.RequestID))
+ if acc.Code != "0" {
+ t.Fatalf("accept %+v", acc)
+ }
+
+ created := authJSON(t, h, &a, http.MethodPost, "/v1/groups", fmt.Sprintf(`{"name":"周末局","member_uids":[%d]}`, b.UserID))
+ if created.Code != "0" {
+ t.Fatalf("create %+v", created)
+ }
+ var g grouplogic.View
+ if err := json.Unmarshal(created.Data, &g); err != nil {
+ t.Fatal(err)
+ }
+ if g.ID != 1 || g.ConversationID == 0 || g.ConversationID == g.ID || g.Name != "周末局" {
+ t.Fatalf("%+v", g)
+ }
+ got := authJSON(t, h, &b, http.MethodGet, fmt.Sprintf("/v1/groups/%d", g.ID), "")
+ if got.Code != "0" {
+ t.Fatalf("get %+v", got)
+ }
+ members := authJSON(t, h, &b, http.MethodGet, fmt.Sprintf("/v1/groups/%d/members", g.ID), "")
+ if members.Code != "0" {
+ t.Fatalf("members %+v", members)
+ }
+}
diff --git a/internal/transport/http/handler/auth/handler.go b/internal/transport/http/handler/auth/handler.go
new file mode 100644
index 0000000..0ae1827
--- /dev/null
+++ b/internal/transport/http/handler/auth/handler.go
@@ -0,0 +1,67 @@
+package authhandler
+
+import (
+ "github.com/gin-gonic/gin"
+
+ authlogic "spark-server/internal/module/auth/logic"
+ "spark-server/internal/transport/http/respond"
+)
+
+// Handler is the auth HTTP controller. It must not contain login business rules.
+type Handler struct {
+ login *authlogic.LoginService
+}
+
+func New(login *authlogic.LoginService) *Handler {
+ return &Handler{login: login}
+}
+
+func (h *Handler) Login(c *gin.Context) {
+ var in authlogic.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)
+}
+
+func (h *Handler) SendEmailCode(c *gin.Context) {
+ var in authlogic.SendCodeInput
+ if err := respond.BindJSON(c, &in); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ out, err := h.login.SendEmailCode(c.Request.Context(), in)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, out)
+}
+
+func (h *Handler) Refresh(c *gin.Context) {
+ var in authlogic.RefreshInput
+ if err := respond.BindJSON(c, &in); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ tok, err := h.login.Refresh(c.Request.Context(), in)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, tok)
+}
+
+func (h *Handler) Logout(c *gin.Context) {
+ if err := h.login.Logout(c.Request.Context()); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, gin.H{})
+}
diff --git a/internal/transport/http/handler/favorite/handler.go b/internal/transport/http/handler/favorite/handler.go
new file mode 100644
index 0000000..5fbd127
--- /dev/null
+++ b/internal/transport/http/handler/favorite/handler.go
@@ -0,0 +1,79 @@
+package favhandler
+
+import (
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+
+ favlogic "spark-server/internal/module/favorite/logic"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/reqctx"
+ "spark-server/internal/transport/http/respond"
+)
+
+// Handler is the favorites HTTP controller.
+type Handler struct {
+ favs *favlogic.Service
+}
+
+func New(favs *favlogic.Service) *Handler {
+ return &Handler{favs: favs}
+}
+
+func (h *Handler) Create(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ var in favlogic.CreateInput
+ if err := respond.BindJSON(c, &in); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ out, err := h.favs.Create(c.Request.Context(), uid, &in)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, out)
+}
+
+func (h *Handler) List(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ limit, _ := strconv.Atoi(c.Query("limit"))
+ out, err := h.favs.List(c.Request.Context(), uid, limit)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, out)
+}
+
+func (h *Handler) Delete(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ id, err := strconv.ParseInt(c.Param("id"), 10, 64)
+ if err != nil || id <= 0 {
+ respond.JSONError(c, errs.ErrInvalidArgument)
+ return
+ }
+ if err := h.favs.Delete(c.Request.Context(), uid, id); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, gin.H{})
+}
+
+func currentUID(c *gin.Context) (int64, bool) {
+ rc := reqctx.From(c.Request.Context())
+ if rc == nil || rc.UserID <= 0 {
+ respond.JSONError(c, errs.ErrUnauthorized)
+ return 0, false
+ }
+ return rc.UserID, true
+}
diff --git a/internal/transport/http/handler/file/handler.go b/internal/transport/http/handler/file/handler.go
new file mode 100644
index 0000000..25dbaa0
--- /dev/null
+++ b/internal/transport/http/handler/file/handler.go
@@ -0,0 +1,64 @@
+package filehandler
+
+import (
+ "github.com/gin-gonic/gin"
+
+ uploadlogic "spark-server/internal/module/upload/logic"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/reqctx"
+ "spark-server/internal/transport/http/respond"
+)
+
+// Handler is the files HTTP controller.
+type Handler struct {
+ files *uploadlogic.Service
+}
+
+func New(files *uploadlogic.Service) *Handler {
+ return &Handler{files: files}
+}
+
+func (h *Handler) Create(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ var in uploadlogic.CreateInput
+ if err := respond.BindJSON(c, &in); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ out, err := h.files.CreateCredential(c.Request.Context(), uid, &in)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, out)
+}
+
+func (h *Handler) Download(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ var in uploadlogic.DownloadInput
+ if err := respond.BindJSON(c, &in); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ out, err := h.files.CreateDownload(c.Request.Context(), uid, &in)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, out)
+}
+
+func currentUID(c *gin.Context) (int64, bool) {
+ rc := reqctx.From(c.Request.Context())
+ if rc == nil || rc.UserID <= 0 {
+ respond.JSONError(c, errs.ErrUnauthorized)
+ return 0, false
+ }
+ return rc.UserID, true
+}
diff --git a/internal/transport/http/handler/friend/handler.go b/internal/transport/http/handler/friend/handler.go
new file mode 100644
index 0000000..ed51405
--- /dev/null
+++ b/internal/transport/http/handler/friend/handler.go
@@ -0,0 +1,131 @@
+package friendhandler
+
+import (
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+
+ friendlogic "spark-server/internal/module/friend/logic"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/reqctx"
+ "spark-server/internal/transport/http/respond"
+)
+
+// Handler is the friends HTTP controller.
+type Handler struct {
+ friends *friendlogic.Service
+}
+
+func New(friends *friendlogic.Service) *Handler {
+ return &Handler{friends: friends}
+}
+
+func (h *Handler) Lookup(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ in := friendlogic.LookupInput{
+ Email: c.Query("email"),
+ SparkID: c.Query("spark_id"),
+ QRCode: c.Query("qrcode"),
+ }
+ if raw := c.Query("uid"); raw != "" {
+ id, err := strconv.ParseInt(raw, 10, 64)
+ if err != nil || id <= 0 {
+ respond.JSONError(c, errs.ErrInvalidArgument)
+ return
+ }
+ in.UID = id
+ }
+ view, err := h.friends.Lookup(c.Request.Context(), uid, in)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, view)
+}
+
+func (h *Handler) Apply(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ var in friendlogic.ApplyInput
+ if err := respond.BindJSON(c, &in); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ out, err := h.friends.Apply(c.Request.Context(), uid, &in)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, out)
+}
+
+func (h *Handler) ListRequests(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ out, err := h.friends.ListRequests(c.Request.Context(), uid, queryLimit(c))
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, out)
+}
+
+func (h *Handler) Accept(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ var in friendlogic.AcceptInput
+ if err := respond.BindJSON(c, &in); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ out, err := h.friends.Accept(c.Request.Context(), uid, in)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, out)
+}
+
+func (h *Handler) List(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ out, err := h.friends.List(c.Request.Context(), uid, queryLimit(c))
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, out)
+}
+
+func currentUID(c *gin.Context) (int64, bool) {
+ rc := reqctx.From(c.Request.Context())
+ if rc == nil || rc.UserID <= 0 {
+ respond.JSONError(c, errs.ErrUnauthorized)
+ return 0, false
+ }
+ return rc.UserID, true
+}
+
+func queryLimit(c *gin.Context) int {
+ n, _ := strconv.Atoi(c.Query("limit"))
+ return n
+}
+
+func pathUID(c *gin.Context) (int64, error) {
+ id, err := strconv.ParseInt(c.Param("uid"), 10, 64)
+ if err != nil || id <= 0 {
+ return 0, errs.ErrInvalidArgument
+ }
+ return id, nil
+}
diff --git a/internal/transport/http/handler/friend/member.go b/internal/transport/http/handler/friend/member.go
new file mode 100644
index 0000000..44e7e16
--- /dev/null
+++ b/internal/transport/http/handler/friend/member.go
@@ -0,0 +1,121 @@
+package friendhandler
+
+import (
+ "github.com/gin-gonic/gin"
+
+ friendlogic "spark-server/internal/module/friend/logic"
+ "spark-server/internal/transport/http/respond"
+)
+
+func (h *Handler) Get(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ friendID, err := pathUID(c)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ view, err := h.friends.Get(c.Request.Context(), uid, friendID)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, view)
+}
+
+func (h *Handler) Delete(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ friendID, err := pathUID(c)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ if err := h.friends.Delete(c.Request.Context(), uid, friendID); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, gin.H{})
+}
+
+func (h *Handler) Remark(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ friendID, err := pathUID(c)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ var in friendlogic.RemarkInput
+ if err := respond.BindJSON(c, &in); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ if err := h.friends.SetRemark(c.Request.Context(), uid, friendID, in); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, gin.H{})
+}
+
+func (h *Handler) Block(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ targetID, err := pathUID(c)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ if err := h.friends.Block(c.Request.Context(), uid, targetID); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, gin.H{})
+}
+
+func (h *Handler) Unblock(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ targetID, err := pathUID(c)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ if err := h.friends.Unblock(c.Request.Context(), uid, targetID); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, gin.H{})
+}
+
+func (h *Handler) Report(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ targetID, err := pathUID(c)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ var in friendlogic.ReportInput
+ if err := respond.BindJSON(c, &in); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ if err := h.friends.Report(c.Request.Context(), uid, targetID, in); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, gin.H{})
+}
diff --git a/internal/transport/http/handler/group/handler.go b/internal/transport/http/handler/group/handler.go
new file mode 100644
index 0000000..629fc26
--- /dev/null
+++ b/internal/transport/http/handler/group/handler.go
@@ -0,0 +1,128 @@
+package grouphandler
+
+import (
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+
+ grouplogic "spark-server/internal/module/group/logic"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/reqctx"
+ "spark-server/internal/transport/http/respond"
+)
+
+// Handler is the groups HTTP controller.
+type Handler struct {
+ groups *grouplogic.Service
+}
+
+func New(groups *grouplogic.Service) *Handler {
+ return &Handler{groups: groups}
+}
+
+func (h *Handler) Create(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ var in grouplogic.CreateInput
+ if err := respond.BindJSON(c, &in); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ out, err := h.groups.Create(c.Request.Context(), uid, &in)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, out)
+}
+
+func (h *Handler) List(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ limit, _ := strconv.Atoi(c.Query("limit"))
+ out, err := h.groups.List(c.Request.Context(), uid, limit)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, out)
+}
+
+func (h *Handler) Get(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ id, err := pathID(c)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ out, err := h.groups.Get(c.Request.Context(), uid, id)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, out)
+}
+
+func (h *Handler) Update(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ id, err := pathID(c)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ var in grouplogic.UpdateInput
+ if err := respond.BindJSON(c, &in); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ out, err := h.groups.Update(c.Request.Context(), uid, id, &in)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, out)
+}
+
+func (h *Handler) Dissolve(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ id, err := pathID(c)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ if err := h.groups.Dissolve(c.Request.Context(), uid, id); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, gin.H{})
+}
+
+func currentUID(c *gin.Context) (int64, bool) {
+ rc := reqctx.From(c.Request.Context())
+ if rc == nil || rc.UserID <= 0 {
+ respond.JSONError(c, errs.ErrUnauthorized)
+ return 0, false
+ }
+ return rc.UserID, true
+}
+
+func pathID(c *gin.Context) (int64, error) {
+ id, err := strconv.ParseInt(c.Param("id"), 10, 64)
+ if err != nil || id <= 0 {
+ return 0, errs.ErrInvalidArgument
+ }
+ return id, nil
+}
diff --git a/internal/transport/http/handler/group/member.go b/internal/transport/http/handler/group/member.go
new file mode 100644
index 0000000..329658e
--- /dev/null
+++ b/internal/transport/http/handler/group/member.go
@@ -0,0 +1,134 @@
+package grouphandler
+
+import (
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+
+ grouplogic "spark-server/internal/module/group/logic"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/transport/http/respond"
+)
+
+func (h *Handler) ListMembers(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ id, err := pathID(c)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ limit, _ := strconv.Atoi(c.Query("limit"))
+ out, err := h.groups.ListMembers(c.Request.Context(), uid, id, limit)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, out)
+}
+
+func (h *Handler) Invite(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ id, err := pathID(c)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ var in grouplogic.InviteInput
+ if err := respond.BindJSON(c, &in); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ out, err := h.groups.Invite(c.Request.Context(), uid, id, &in)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, out)
+}
+
+func (h *Handler) RemoveMember(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ id, err := pathID(c)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ target, err := pathUID(c)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ if err := h.groups.RemoveMember(c.Request.Context(), uid, id, target); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, gin.H{})
+}
+
+func (h *Handler) UpdateMember(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ id, err := pathID(c)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ target, err := pathUID(c)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ var in grouplogic.UpdateMemberInput
+ if err := respond.BindJSON(c, &in); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ out, err := h.groups.UpdateMember(c.Request.Context(), uid, id, target, &in)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, out)
+}
+
+func (h *Handler) Transfer(c *gin.Context) {
+ uid, ok := currentUID(c)
+ if !ok {
+ return
+ }
+ id, err := pathID(c)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ var in grouplogic.TransferInput
+ if err := respond.BindJSON(c, &in); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ out, err := h.groups.Transfer(c.Request.Context(), uid, id, &in)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, out)
+}
+
+func pathUID(c *gin.Context) (int64, error) {
+ id, err := strconv.ParseInt(c.Param("uid"), 10, 64)
+ if err != nil || id <= 0 {
+ return 0, errs.ErrInvalidArgument
+ }
+ return id, nil
+}
diff --git a/internal/transport/http/handler/user/handler.go b/internal/transport/http/handler/user/handler.go
new file mode 100644
index 0000000..2839f2f
--- /dev/null
+++ b/internal/transport/http/handler/user/handler.go
@@ -0,0 +1,52 @@
+package userhandler
+
+import (
+ "github.com/gin-gonic/gin"
+
+ userlogic "spark-server/internal/module/user/logic"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/reqctx"
+ "spark-server/internal/transport/http/respond"
+)
+
+// Handler is the users HTTP controller.
+type Handler struct {
+ users *userlogic.Service
+}
+
+func New(users *userlogic.Service) *Handler {
+ return &Handler{users: users}
+}
+
+func (h *Handler) Me(c *gin.Context) {
+ rc := reqctx.From(c.Request.Context())
+ if rc == nil || rc.UserID <= 0 {
+ respond.JSONError(c, errs.ErrUnauthorized)
+ return
+ }
+ u, err := h.users.Get(c.Request.Context(), rc.UserID)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, userlogic.PublicProfile(u))
+}
+
+func (h *Handler) PatchMe(c *gin.Context) {
+ rc := reqctx.From(c.Request.Context())
+ if rc == nil || rc.UserID <= 0 {
+ respond.JSONError(c, errs.ErrUnauthorized)
+ return
+ }
+ var in userlogic.UpdateProfileInput
+ if err := respond.BindJSON(c, &in); err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ u, err := h.users.UpdateProfile(c.Request.Context(), rc.UserID, in)
+ if err != nil {
+ respond.JSONError(c, err)
+ return
+ }
+ respond.JSON(c, userlogic.PublicProfile(u))
+}
diff --git a/internal/transport/http/middleware.go b/internal/transport/http/middleware.go
new file mode 100644
index 0000000..94d0ab2
--- /dev/null
+++ b/internal/transport/http/middleware.go
@@ -0,0 +1,150 @@
+package httpx
+
+import (
+ "bytes"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+
+ "spark-server/internal/infrastructure/logger"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
+ "spark-server/internal/pkg/reply"
+ "spark-server/internal/pkg/reqctx"
+)
+
+const (
+ headerRequestID = "X-Request-ID"
+ headerTraceID = "X-Trace-ID"
+)
+
+func Trace(base *zap.Logger) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ reqID := c.GetHeader(headerRequestID)
+ if reqID == "" {
+ reqID = reqctx.NewID()
+ }
+ traceID := c.GetHeader(headerTraceID)
+ if traceID == "" {
+ traceID = reqID
+ }
+ rc := &reqctx.RequestContext{
+ TraceID: traceID,
+ RequestID: reqID,
+ RemoteAddr: c.Request.RemoteAddr,
+ RealIP: reqctx.ParseHTTPRealIP(
+ c.GetHeader(headerRealIP),
+ c.GetHeader(headerForwardedFor),
+ c.Request.RemoteAddr,
+ ),
+ AppVersion: c.GetHeader(headerAppVersion),
+ Language: requestLanguage(c),
+ }
+ ctx := reqctx.With(c.Request.Context(), rc)
+ c.Request = c.Request.WithContext(logger.Attach(ctx, base))
+ c.Header(headerRequestID, reqID)
+ c.Header(headerTraceID, traceID)
+ c.Next()
+ }
+}
+
+func Recovery(_ *zap.Logger) gin.HandlerFunc {
+ return gin.CustomRecovery(func(c *gin.Context, recovered any) {
+ logger.From(c.Request.Context()).Error("panic", zap.Any("panic", recovered), zap.String("path", c.Request.URL.Path))
+ c.Abort()
+ c.JSON(http.StatusOK, reply.FromContext(c.Request.Context(), errs.ErrInner.WithInnerMsg("panic")))
+ })
+}
+
+func AccessLog() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if skipAccessLog(c.Request.URL.Path) {
+ c.Next()
+ return
+ }
+ reqBody := readRequestBody(c)
+ bw := &bodyWriter{ResponseWriter: c.Writer}
+ c.Writer = bw
+ start := time.Now()
+ c.Next()
+ logger.From(c.Request.Context()).Info("http_access",
+ zap.String("method", c.Request.Method),
+ zap.String("path", c.Request.URL.Path),
+ zap.Int("status", c.Writer.Status()),
+ zap.Int64("latency_ms", time.Since(start).Milliseconds()),
+ zap.String("request_body", logger.ClipBody(reqBody)),
+ zap.String("response_body", logger.ClipBody(bw.buf.Bytes())),
+ )
+ }
+}
+
+func skipAccessLog(path string) bool {
+ switch path {
+ case "/healthz", "/readyz", "/openapi.yaml", "/docs", "/docs/":
+ return true
+ default:
+ return strings.HasPrefix(path, "/docs/")
+ }
+}
+
+func readRequestBody(c *gin.Context) []byte {
+ if c.Request.Body == nil {
+ return nil
+ }
+ if ct := c.ContentType(); strings.HasPrefix(ct, "multipart/") {
+ return []byte("")
+ }
+ raw, err := io.ReadAll(io.LimitReader(c.Request.Body, int64(logger.MaxBody)+1))
+ if err != nil {
+ return nil
+ }
+ _ = c.Request.Body.Close()
+ c.Request.Body = io.NopCloser(bytes.NewReader(raw))
+ return raw
+}
+
+type bodyWriter struct {
+ gin.ResponseWriter
+ buf bytes.Buffer
+}
+
+func (w *bodyWriter) Write(b []byte) (int, error) {
+ w.capture(b)
+ return w.ResponseWriter.Write(b)
+}
+
+func (w *bodyWriter) WriteString(s string) (int, error) {
+ if w.buf.Len() < logger.MaxBody {
+ remain := logger.MaxBody - w.buf.Len()
+ if len(s) < remain {
+ _, _ = w.buf.WriteString(s)
+ } else {
+ _, _ = w.buf.WriteString(s[:remain])
+ }
+ }
+ return w.ResponseWriter.WriteString(s)
+}
+
+func (w *bodyWriter) capture(b []byte) {
+ if w.buf.Len() >= logger.MaxBody {
+ return
+ }
+ remain := logger.MaxBody - w.buf.Len()
+ if len(b) < remain {
+ _, _ = w.buf.Write(b)
+ return
+ }
+ _, _ = w.buf.Write(b[:remain])
+}
+
+func requestLanguage(c *gin.Context) string {
+ raw := c.GetHeader(headerXLanguage)
+ if raw == "" {
+ raw = c.GetHeader(headerLanguage)
+ }
+ return i18n.Parse(raw).String()
+}
diff --git a/internal/transport/http/respond/respond.go b/internal/transport/http/respond/respond.go
new file mode 100644
index 0000000..509bc13
--- /dev/null
+++ b/internal/transport/http/respond/respond.go
@@ -0,0 +1,39 @@
+package respond
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+
+ "spark-server/internal/infrastructure/logger"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
+ "spark-server/internal/pkg/reply"
+ "spark-server/internal/pkg/reqctx"
+ "spark-server/internal/pkg/validate"
+)
+
+func JSON(c *gin.Context, data any) {
+ c.JSON(http.StatusOK, reply.OK(data))
+}
+
+func JSONError(c *gin.Context, err error) {
+ if app, ok := errs.AsAppError(err); ok && app.LogPrint {
+ logger.From(c.Request.Context()).Error("app_error",
+ zap.String("code", app.Code),
+ zap.String("msg", app.Msg),
+ zap.String("inner_msg", app.InnerMsg),
+ )
+ }
+ c.JSON(http.StatusOK, reply.FromContext(c.Request.Context(), err))
+}
+
+// BindJSON decodes JSON then runs go-playground/validator. label/errmsg are i18n keys.
+func BindJSON(c *gin.Context, dst any) error {
+ lang := reqctx.Lang(c.Request.Context())
+ if err := c.ShouldBindJSON(dst); err != nil {
+ return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeyValidateInvalidJSON, nil))
+ }
+ return validate.StructLang(lang, dst)
+}
diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go
new file mode 100644
index 0000000..9e91196
--- /dev/null
+++ b/internal/transport/http/router.go
@@ -0,0 +1,146 @@
+package httpx
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
+ "spark-server/internal/pkg/reply"
+ "spark-server/internal/pkg/reqctx"
+ authhandler "spark-server/internal/transport/http/handler/auth"
+ favhandler "spark-server/internal/transport/http/handler/favorite"
+ filehandler "spark-server/internal/transport/http/handler/file"
+ friendhandler "spark-server/internal/transport/http/handler/friend"
+ grouphandler "spark-server/internal/transport/http/handler/group"
+ userhandler "spark-server/internal/transport/http/handler/user"
+ "spark-server/internal/transport/http/respond"
+)
+
+func registerRoutes(r *gin.Engine, deps *EngineDeps) {
+ registerDocs(r)
+ registerHealth(r, deps.Health)
+ v1 := r.Group("/v1")
+ registerAuthRoutes(v1, deps)
+ registerUserRoutes(v1, deps)
+ registerFriendRoutes(v1, deps)
+ registerGroupRoutes(v1, deps)
+ registerFileRoutes(v1, deps)
+ registerFavoriteRoutes(v1, deps)
+}
+
+func registerHealth(r *gin.Engine, h Health) {
+ r.GET("/healthz", func(c *gin.Context) {
+ JSON(c, gin.H{"status": "ok"})
+ })
+ r.GET("/readyz", func(c *gin.Context) {
+ if h == nil {
+ JSON(c, gin.H{"status": "ok", "ready": true})
+ return
+ }
+ ok, data := h.Ready(c.Request.Context())
+ if ok {
+ JSON(c, data)
+ return
+ }
+ lang := reqctx.Lang(c.Request.Context())
+ c.JSON(http.StatusServiceUnavailable, reply.FailData(
+ errs.ErrAvailable.Code,
+ i18n.ErrorMsg(lang, errs.ErrAvailable.Code),
+ data,
+ ))
+ })
+}
+
+func registerAuthRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
+ g := v1.Group("/auth")
+ if deps.Login == nil {
+ return
+ }
+ h := authhandler.New(deps.Login)
+ g.POST("/login", h.Login)
+ g.POST("/email-codes", h.SendEmailCode)
+ g.POST("/token/refresh", h.Refresh)
+ g.POST("/logout", h.Logout)
+}
+
+func registerUserRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
+ if deps.Users == nil {
+ return
+ }
+ h := userhandler.New(deps.Users)
+ g := v1.Group("/users")
+ g.GET("/me", h.Me)
+ g.PATCH("/me", h.PatchMe)
+}
+
+func registerFriendRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
+ if deps.Friends == nil {
+ return
+ }
+ h := friendhandler.New(deps.Friends)
+ g := v1.Group("/friends")
+ g.GET("/lookup", h.Lookup)
+ g.GET("/requests", h.ListRequests)
+ g.POST("/requests", h.Apply)
+ g.GET("", h.List)
+ g.POST("", h.Accept)
+ g.GET("/:uid", h.Get)
+ g.DELETE("/:uid", h.Delete)
+ g.PUT("/:uid/remark", h.Remark)
+ g.PUT("/:uid/block", h.Block)
+ g.DELETE("/:uid/block", h.Unblock)
+ g.POST("/:uid/reports", h.Report)
+}
+
+func registerGroupRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
+ if deps.Groups == nil {
+ return
+ }
+ h := grouphandler.New(deps.Groups)
+ g := v1.Group("/groups")
+ g.POST("", h.Create)
+ g.GET("", h.List)
+ g.GET("/:id/members", h.ListMembers)
+ g.POST("/:id/members", h.Invite)
+ g.PATCH("/:id/members/:uid", h.UpdateMember)
+ g.DELETE("/:id/members/:uid", h.RemoveMember)
+ g.POST("/:id/transfer", h.Transfer)
+ g.GET("/:id", h.Get)
+ g.PATCH("/:id", h.Update)
+ g.DELETE("/:id", h.Dissolve)
+}
+
+func registerFileRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
+ if deps.Files == nil {
+ return
+ }
+ h := filehandler.New(deps.Files)
+ g := v1.Group("/files")
+ g.POST("", h.Create)
+ g.POST("/downloads", h.Download)
+}
+
+func registerFavoriteRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
+ if deps.Favorites == nil {
+ return
+ }
+ h := favhandler.New(deps.Favorites)
+ g := v1.Group("/favorites")
+ g.GET("", h.List)
+ g.POST("", h.Create)
+ g.DELETE("/:id", h.Delete)
+}
+
+func JSON(c *gin.Context, data any) {
+ respond.JSON(c, data)
+}
+
+func JSONError(c *gin.Context, err error) {
+ respond.JSONError(c, err)
+}
+
+func BindJSON(c *gin.Context, dst any) error {
+ return respond.BindJSON(c, dst)
+}
diff --git a/internal/transport/http/server.go b/internal/transport/http/server.go
index ef8fbc6..5bea5a4 100644
--- a/internal/transport/http/server.go
+++ b/internal/transport/http/server.go
@@ -2,27 +2,62 @@ package httpx
import (
"context"
- "log/slog"
+ "fmt"
+ "net"
"net/http"
+ "sync"
+ "sync/atomic"
"time"
"github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+
+ authlogic "spark-server/internal/module/auth/logic"
+ favlogic "spark-server/internal/module/favorite/logic"
+ friendlogic "spark-server/internal/module/friend/logic"
+ grouplogic "spark-server/internal/module/group/logic"
+ uploadlogic "spark-server/internal/module/upload/logic"
+ userlogic "spark-server/internal/module/user/logic"
+ "spark-server/internal/pkg/ratelimit"
)
type Server struct {
- log *slog.Logger
- http *http.Server
+ log *zap.Logger
+ http *http.Server
+ ready chan struct{}
+ readyOnce sync.Once
+ listening atomic.Bool
}
-func New(log *slog.Logger, addr string) *Server {
- gin.SetMode(gin.ReleaseMode)
- r := gin.New()
- r.Use(gin.Recovery())
- r.GET("/healthz", func(c *gin.Context) {
- c.JSON(http.StatusOK, gin.H{"status": "ok"})
- })
+// Health is the readiness checker. Nil means tests: /readyz is always ready.
+type Health interface {
+ Ready(ctx context.Context) (ok bool, data any)
+}
+
+// EngineDeps wires Application services into Gin. Auth nil disables Bearer checks (tests).
+type EngineDeps struct {
+ Auth authlogic.Service
+ Login *authlogic.LoginService
+ Users *userlogic.Service
+ Friends *friendlogic.Service
+ Groups *grouplogic.Service
+ Files *uploadlogic.Service
+ Favorites *favlogic.Service
+ Health Health
+ AuthLimiter *ratelimit.Limiter
+}
+
+func New(log *zap.Logger, addr string, deps *EngineDeps) *Server {
+ if log == nil {
+ log = zap.NewNop()
+ }
+ if deps == nil {
+ deps = &EngineDeps{}
+ }
+ r := NewEngine(log, deps)
return &Server{
- log: log,
+ log: log,
+ ready: make(chan struct{}),
http: &http.Server{
Addr: addr,
Handler: r,
@@ -31,15 +66,50 @@ func New(log *slog.Logger, addr string) *Server {
}
}
+func NewEngine(log *zap.Logger, deps *EngineDeps) *gin.Engine {
+ gin.SetMode(gin.ReleaseMode)
+ r := gin.New()
+ _ = r.SetTrustedProxies([]string{"127.0.0.1", "::1"})
+ if deps == nil {
+ deps = &EngineDeps{}
+ }
+ r.Use(Trace(log), Recovery(log), AccessLog(), AuthRateLimit(deps.AuthLimiter), Auth(deps.Auth))
+ registerRoutes(r, deps)
+ return r
+}
+
func (s *Server) Start() error {
- s.log.Info("http listening", "addr", s.http.Addr)
- err := s.http.ListenAndServe()
+ ln, err := net.Listen("tcp", s.http.Addr)
+ if err != nil {
+ s.signalReady()
+ return err
+ }
+ s.listening.Store(true)
+ s.signalReady()
+ s.log.Info("http listening", zap.String("addr", ln.Addr().String()))
+ err = s.http.Serve(ln)
if err == http.ErrServerClosed {
return nil
}
return err
}
+func (s *Server) WaitReady(ctx context.Context) error {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-s.ready:
+ if s.listening.Load() {
+ return nil
+ }
+ return fmt.Errorf("http not listening")
+ }
+}
+
+func (s *Server) signalReady() {
+ s.readyOnce.Do(func() { close(s.ready) })
+}
+
func (s *Server) Stop(ctx context.Context) error {
return s.http.Shutdown(ctx)
}
diff --git a/internal/transport/http/server_test.go b/internal/transport/http/server_test.go
new file mode 100644
index 0000000..48c1ddc
--- /dev/null
+++ b/internal/transport/http/server_test.go
@@ -0,0 +1,434 @@
+package httpx
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+
+ "spark-server/internal/infrastructure/id"
+ "spark-server/internal/infrastructure/logger"
+ authentity "spark-server/internal/module/auth/entity"
+ authlogic "spark-server/internal/module/auth/logic"
+ userlogic "spark-server/internal/module/user/logic"
+ usermodel "spark-server/internal/module/user/model"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/ratelimit"
+ "spark-server/internal/pkg/reply"
+ "spark-server/internal/pkg/reqctx"
+)
+
+func TestHealthzEnvelopeAndTraceHeaders(t *testing.T) {
+ r := NewEngine(zap.NewNop(), &EngineDeps{})
+ req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
+ req.Header.Set("X-Request-ID", "req-1")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status %d", w.Code)
+ }
+ if w.Header().Get("X-Request-ID") != "req-1" || w.Header().Get("X-Trace-ID") != "req-1" {
+ t.Fatalf("headers %v", w.Header())
+ }
+ var env reply.Envelope
+ if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
+ t.Fatal(err)
+ }
+ if env.Code != "0" || env.Msg != "ok" {
+ t.Fatalf("%+v", env)
+ }
+ if !bytes.Contains(env.Data, []byte(`"status":"ok"`)) {
+ t.Fatalf("data %s", env.Data)
+ }
+}
+
+type stubHealth struct {
+ ok bool
+ data any
+}
+
+func (s stubHealth) Ready(context.Context) (bool, any) { return s.ok, s.data }
+
+func TestReadyzHTTPStatus(t *testing.T) {
+ r := NewEngine(zap.NewNop(), &EngineDeps{Health: stubHealth{ok: true, data: gin.H{"status": "ok", "mysql": "ok"}}})
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil))
+ if w.Code != http.StatusOK {
+ t.Fatalf("ready %d %s", w.Code, w.Body.String())
+ }
+ r = NewEngine(zap.NewNop(), &EngineDeps{Health: stubHealth{ok: false, data: gin.H{"status": "not_ready", "mysql": "down"}}})
+ w = httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil))
+ if w.Code != http.StatusServiceUnavailable {
+ t.Fatalf("not ready %d", w.Code)
+ }
+ var env reply.Envelope
+ if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
+ t.Fatal(err)
+ }
+ if env.Code != errs.ErrAvailable.Code || !bytes.Contains(env.Data, []byte(`"mysql":"down"`)) {
+ t.Fatalf("%+v", env)
+ }
+}
+
+func TestDocsAndOpenAPI(t *testing.T) {
+ r := NewEngine(zap.NewNop(), &EngineDeps{})
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/openapi.yaml", nil))
+ if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "openapi: 3.0.3") || !strings.Contains(w.Body.String(), "bearerAuth") {
+ t.Fatalf("spec %d %s", w.Code, w.Body.String())
+ }
+ w = httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs", nil))
+ if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "swagger-ui") {
+ t.Fatalf("docs %d", w.Code)
+ }
+}
+
+func TestJSONErrorEnvelope(t *testing.T) {
+ r := NewEngine(zap.NewNop(), &EngineDeps{})
+ r.POST("/v1/probe", func(c *gin.Context) {
+ JSONError(c, errs.ErrNotFound.WithMsg("missing"))
+ })
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/v1/probe", strings.NewReader(`{"token":"secret"}`))
+ req.Header.Set("Content-Type", "application/json")
+ r.ServeHTTP(w, req)
+ var env reply.Envelope
+ if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
+ t.Fatal(err)
+ }
+ if env.Code != errs.ErrNotFound.Code || env.Msg != "missing" {
+ t.Fatalf("%+v", env)
+ }
+}
+
+func TestClipBodyRedactsToken(t *testing.T) {
+ got := logger.ClipBody([]byte(`{"token":"abc","access_token":"jwt","refresh_token":"rt","verify_code":"654321","upload_url":"http://x?sig=1","x":1}`))
+ if strings.Contains(got, "abc") || strings.Contains(got, "jwt") || strings.Contains(got, "rt") || strings.Contains(got, "654321") || strings.Contains(got, "sig=1") || !strings.Contains(got, "***") {
+ t.Fatalf("redact %s", got)
+ }
+}
+
+func testLoginEngine(t *testing.T) *gin.Engine {
+ t.Helper()
+ j, err := authlogic.NewJWT(&authlogic.Options{Secret: "test-secret-test-secret", Issuer: "spark-im"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ ids, err := id.New(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil)
+ return NewEngine(zap.NewNop(), &EngineDeps{
+ Auth: j,
+ Login: authlogic.NewLoginService(j, users),
+ Users: users,
+ })
+}
+
+func TestLoginAndBearerAuth(t *testing.T) {
+ r := testLoginEngine(t)
+ r.GET("/v1/probe", func(c *gin.Context) {
+ rc := reqctx.From(c.Request.Context())
+ JSON(c, gin.H{"user_id": rc.UserID, "device_id": rc.DeviceID})
+ })
+
+ w := httptest.NewRecorder()
+ loginReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com","verify_code":"any","device_id":7,"device_type":"ios","device_name":"iPhone"}`))
+ loginReq.Header.Set("Content-Type", "application/json")
+ loginReq.Header.Set("X-App-Version", "1.0.0")
+ loginReq.Header.Set("X-Real-IP", "203.0.113.10")
+ r.ServeHTTP(w, loginReq)
+ var loginEnv reply.Envelope
+ if err := json.Unmarshal(w.Body.Bytes(), &loginEnv); err != nil {
+ t.Fatal(err)
+ }
+ if loginEnv.Code != "0" {
+ t.Fatalf("login %+v body=%s", loginEnv, w.Body.String())
+ }
+ var tok authentity.IssuedToken
+ if err := json.Unmarshal(loginEnv.Data, &tok); err != nil {
+ t.Fatal(err)
+ }
+ if tok.AccessToken == "" || tok.RefreshToken == "" || tok.UserID <= 0 || tok.DeviceID != 7 || tok.ExpiresIn != 604800 || tok.RefreshExpiresIn != 2592000 || tok.SparkID == "" {
+ t.Fatalf("login data %+v", tok)
+ }
+
+ w = httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/probe", nil))
+ var denied reply.Envelope
+ if err := json.Unmarshal(w.Body.Bytes(), &denied); err != nil {
+ t.Fatal(err)
+ }
+ if denied.Code != errs.ErrUnauthorized.Code {
+ t.Fatalf("no bearer %+v", denied)
+ }
+
+ w = httptest.NewRecorder()
+ okReq := httptest.NewRequest(http.MethodGet, "/v1/probe", nil)
+ okReq.Header.Set("Authorization", "Bearer "+tok.AccessToken)
+ okReq.Header.Set("X-App-Version", "1.0.0")
+ okReq.Header.Set("Accept-Language", "zh-CN")
+ r.ServeHTTP(w, okReq)
+ var okEnv reply.Envelope
+ if err := json.Unmarshal(w.Body.Bytes(), &okEnv); err != nil {
+ t.Fatal(err)
+ }
+ uidJSON, _ := json.Marshal(tok.UserID)
+ if okEnv.Code != "0" || !bytes.Contains(okEnv.Data, uidJSON) || !bytes.Contains(okEnv.Data, []byte(`"device_id":7`)) {
+ t.Fatalf("authed %+v", okEnv)
+ }
+
+ w = httptest.NewRecorder()
+ badDev := httptest.NewRequest(http.MethodGet, "/v1/probe", nil)
+ badDev.Header.Set("Authorization", "Bearer "+tok.AccessToken)
+ badDev.Header.Set("X-Device-ID", "99")
+ r.ServeHTTP(w, badDev)
+ var mismatch reply.Envelope
+ if err := json.Unmarshal(w.Body.Bytes(), &mismatch); err != nil {
+ t.Fatal(err)
+ }
+ if mismatch.Code != errs.ErrUnauthorized.Code {
+ t.Fatalf("device mismatch %+v", mismatch)
+ }
+}
+
+func TestUsersMeAndSparkID(t *testing.T) {
+ r := testLoginEngine(t)
+ w := httptest.NewRecorder()
+ loginReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com","verify_code":"any","device_id":7,"device_type":"ios"}`))
+ loginReq.Header.Set("Content-Type", "application/json")
+ r.ServeHTTP(w, loginReq)
+ var loginEnv reply.Envelope
+ if err := json.Unmarshal(w.Body.Bytes(), &loginEnv); err != nil {
+ t.Fatal(err)
+ }
+ if loginEnv.Code != "0" {
+ t.Fatalf("login %+v", loginEnv)
+ }
+ var tok authentity.IssuedToken
+ if err := json.Unmarshal(loginEnv.Data, &tok); err != nil {
+ t.Fatal(err)
+ }
+
+ w = httptest.NewRecorder()
+ meReq := httptest.NewRequest(http.MethodGet, "/v1/users/me", nil)
+ meReq.Header.Set("Authorization", "Bearer "+tok.AccessToken)
+ r.ServeHTTP(w, meReq)
+ var meEnv reply.Envelope
+ if err := json.Unmarshal(w.Body.Bytes(), &meEnv); err != nil {
+ t.Fatal(err)
+ }
+ hasIDs := bytes.Contains(meEnv.Data, []byte(`"spark_id"`)) && bytes.Contains(meEnv.Data, []byte(`"uid"`)) && bytes.Contains(meEnv.Data, []byte(`"qrcode"`))
+ if meEnv.Code != "0" || !hasIDs || bytes.Contains(meEnv.Data, []byte(`spark:u:`)) || bytes.Contains(meEnv.Data, []byte(`spark:q:`)) {
+ t.Fatalf("me %+v", meEnv)
+ }
+
+ w = httptest.NewRecorder()
+ patchReq := httptest.NewRequest(http.MethodPatch, "/v1/users/me", strings.NewReader(`{"spark_id":"alice_ok"}`))
+ patchReq.Header.Set("Content-Type", "application/json")
+ patchReq.Header.Set("Authorization", "Bearer "+tok.AccessToken)
+ r.ServeHTTP(w, patchReq)
+ var patchEnv reply.Envelope
+ if err := json.Unmarshal(w.Body.Bytes(), &patchEnv); err != nil {
+ t.Fatal(err)
+ }
+ if patchEnv.Code != "0" || !bytes.Contains(patchEnv.Data, []byte(`"alice_ok"`)) {
+ t.Fatalf("patch %+v", patchEnv)
+ }
+
+ w = httptest.NewRecorder()
+ profReq := httptest.NewRequest(http.MethodPatch, "/v1/users/me", strings.NewReader(`{"nickname":"Ada","gender":2,"birthday":"1994-05-01"}`))
+ profReq.Header.Set("Content-Type", "application/json")
+ profReq.Header.Set("Authorization", "Bearer "+tok.AccessToken)
+ r.ServeHTTP(w, profReq)
+ var profEnv reply.Envelope
+ if err := json.Unmarshal(w.Body.Bytes(), &profEnv); err != nil {
+ t.Fatal(err)
+ }
+ if profEnv.Code != "0" || !bytes.Contains(profEnv.Data, []byte(`"Ada"`)) || !bytes.Contains(profEnv.Data, []byte(`"1994-05-01"`)) {
+ t.Fatalf("profile patch %+v", profEnv)
+ }
+}
+
+func TestRefreshAndLogout(t *testing.T) {
+ r := testLoginEngine(t)
+ w := httptest.NewRecorder()
+ loginReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com","verify_code":"any","device_id":7,"device_type":"ios"}`))
+ loginReq.Header.Set("Content-Type", "application/json")
+ r.ServeHTTP(w, loginReq)
+ var loginEnv reply.Envelope
+ if err := json.Unmarshal(w.Body.Bytes(), &loginEnv); err != nil {
+ t.Fatal(err)
+ }
+ if loginEnv.Code != "0" {
+ t.Fatalf("login %+v", loginEnv)
+ }
+ var tok authentity.IssuedToken
+ if err := json.Unmarshal(loginEnv.Data, &tok); err != nil {
+ t.Fatal(err)
+ }
+
+ w = httptest.NewRecorder()
+ refReq := httptest.NewRequest(http.MethodPost, "/v1/auth/token/refresh", strings.NewReader(`{"refresh_token":"`+tok.RefreshToken+`"}`))
+ refReq.Header.Set("Content-Type", "application/json")
+ r.ServeHTTP(w, refReq)
+ var refEnv reply.Envelope
+ if err := json.Unmarshal(w.Body.Bytes(), &refEnv); err != nil {
+ t.Fatal(err)
+ }
+ if refEnv.Code != "0" {
+ t.Fatalf("refresh %+v", refEnv)
+ }
+ var next authentity.IssuedToken
+ if err := json.Unmarshal(refEnv.Data, &next); err != nil {
+ t.Fatal(err)
+ }
+ if next.AccessToken == "" || next.AccessToken == tok.AccessToken || next.RefreshToken == tok.RefreshToken {
+ t.Fatalf("next %+v", next)
+ }
+
+ w = httptest.NewRecorder()
+ oldMe := httptest.NewRequest(http.MethodGet, "/v1/users/me", nil)
+ oldMe.Header.Set("Authorization", "Bearer "+tok.AccessToken)
+ r.ServeHTTP(w, oldMe)
+ var oldEnv reply.Envelope
+ if err := json.Unmarshal(w.Body.Bytes(), &oldEnv); err != nil {
+ t.Fatal(err)
+ }
+ if oldEnv.Code != errs.ErrUnauthorized.Code {
+ t.Fatalf("old access %+v", oldEnv)
+ }
+
+ w = httptest.NewRecorder()
+ outReq := httptest.NewRequest(http.MethodPost, "/v1/auth/logout", nil)
+ outReq.Header.Set("Authorization", "Bearer "+next.AccessToken)
+ r.ServeHTTP(w, outReq)
+ var outEnv reply.Envelope
+ if err := json.Unmarshal(w.Body.Bytes(), &outEnv); err != nil {
+ t.Fatal(err)
+ }
+ if outEnv.Code != "0" {
+ t.Fatalf("logout %+v", outEnv)
+ }
+
+ w = httptest.NewRecorder()
+ meReq := httptest.NewRequest(http.MethodGet, "/v1/users/me", nil)
+ meReq.Header.Set("Authorization", "Bearer "+next.AccessToken)
+ r.ServeHTTP(w, meReq)
+ var meEnv reply.Envelope
+ if err := json.Unmarshal(w.Body.Bytes(), &meEnv); err != nil {
+ t.Fatal(err)
+ }
+ if meEnv.Code != errs.ErrUnauthorized.Code {
+ t.Fatalf("after logout %+v", meEnv)
+ }
+}
+
+func TestLoginRejectsBadInput(t *testing.T) {
+ r := testLoginEngine(t)
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com"}`))
+ req.Header.Set("Content-Type", "application/json")
+ r.ServeHTTP(w, req)
+ var env reply.Envelope
+ if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
+ t.Fatal(err)
+ }
+ if env.Code != errs.ErrInvalidArgument.Code || !strings.Contains(env.Msg, "请填写验证码") {
+ t.Fatalf("%+v", env)
+ }
+
+ w = httptest.NewRecorder()
+ enReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com"}`))
+ enReq.Header.Set("Content-Type", "application/json")
+ enReq.Header.Set("Accept-Language", "en")
+ r.ServeHTTP(w, enReq)
+ if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
+ t.Fatal(err)
+ }
+ if env.Code != errs.ErrInvalidArgument.Code || !strings.Contains(env.Msg, "Please enter the verification code") {
+ t.Fatalf("en %+v", env)
+ }
+}
+
+func TestHTTPRealIPOnContext(t *testing.T) {
+ r := NewEngine(zap.NewNop(), &EngineDeps{})
+ r.GET("/v1/probe-ip", func(c *gin.Context) {
+ JSON(c, gin.H{"real_ip": reqctx.RealIP(c.Request.Context())})
+ })
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/v1/probe-ip", nil)
+ req.Header.Set("X-Real-IP", "203.0.113.10")
+ r.ServeHTTP(w, req)
+ var env reply.Envelope
+ if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
+ t.Fatal(err)
+ }
+ if env.Code != "0" || !bytes.Contains(env.Data, []byte(`"real_ip":"203.0.113.10"`)) {
+ t.Fatalf("%+v", env)
+ }
+}
+
+func TestHTTPLanguageOnContext(t *testing.T) {
+ r := NewEngine(zap.NewNop(), &EngineDeps{})
+ r.GET("/v1/probe-lang", func(c *gin.Context) {
+ JSON(c, gin.H{"lang": reqctx.Language(c.Request.Context())})
+ })
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/v1/probe-lang", nil)
+ req.Header.Set("X-Language", "zh-TW")
+ r.ServeHTTP(w, req)
+ var env reply.Envelope
+ if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
+ t.Fatal(err)
+ }
+ if env.Code != "0" || !bytes.Contains(env.Data, []byte(`"lang":"zh-TW"`)) {
+ t.Fatalf("%+v", env)
+ }
+}
+
+func TestLoginRateLimitedByRealIP(t *testing.T) {
+ j, err := authlogic.NewJWT(&authlogic.Options{Secret: "test-secret-test-secret", Issuer: "spark-im"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ ids, err := id.New(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil)
+ r := NewEngine(zap.NewNop(), &EngineDeps{
+ Auth: j,
+ Login: authlogic.NewLoginService(j, users),
+ Users: users,
+ AuthLimiter: ratelimit.New(1),
+ })
+ body := `{"email":"test01@spark.com","verify_code":"x","device_id":7,"device_type":"ios"}`
+ hit := func() reply.Envelope {
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Real-IP", "203.0.113.10")
+ r.ServeHTTP(w, req)
+ var env reply.Envelope
+ if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
+ t.Fatal(err)
+ }
+ return env
+ }
+ if env := hit(); env.Code != "0" {
+ t.Fatalf("first %+v", env)
+ }
+ if env := hit(); env.Code != errs.ErrRateLimited.Code {
+ t.Fatalf("second %+v", env)
+ }
+}
diff --git a/internal/transport/tcp/dispatch.go b/internal/transport/tcp/dispatch.go
index b955af5..752c687 100644
--- a/internal/transport/tcp/dispatch.go
+++ b/internal/transport/tcp/dispatch.go
@@ -2,8 +2,13 @@ package tcp
import (
"context"
+ "strconv"
+ "go.uber.org/zap"
+
+ "spark-server/internal/infrastructure/logger"
"spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
"spark-server/internal/pkg/reqctx"
"spark-server/internal/transport/tcp/protocol"
"spark-server/internal/transport/tcp/session"
@@ -11,7 +16,13 @@ import (
func (s *Server) handleInbound(st *connState, frame protocol.Frame) {
st.sess.Touch()
- if frame.Command != protocol.CmdHeartbeat && frame.Command != protocol.CmdPushAck && !st.limit.Allow() {
+ if frame.Command == protocol.CmdAuth {
+ if !s.allowAuthIP(st.sess.RealIP()) {
+ s.metrics.Inc("tcp_auth_rate_limited_total")
+ s.replyErr(st, frame, errs.ErrRateLimited)
+ return
+ }
+ } else if frame.Command != protocol.CmdHeartbeat && frame.Command != protocol.CmdPushAck && !st.limit.Allow() {
s.metrics.Inc("tcp_rate_limited_total")
s.replyErr(st, frame, errs.ErrRateLimited)
return
@@ -22,7 +33,7 @@ func (s *Server) handleInbound(st *connState, frame protocol.Frame) {
}
switch frame.Command {
case protocol.CmdHeartbeat:
- s.writeEncoded(st, replyFrame(protocol.CmdHeartbeat, frame.SequenceID, frame.RequestID, nil, 0))
+ s.writeEncoded(st, replyFrame(protocol.CmdHeartbeat, frame.SequenceID, frame.RequestID, protocol.EncodeOK(nil), 0))
case protocol.CmdPushAck:
return
default:
@@ -43,7 +54,13 @@ func ensureAuth(conn *session.Conn, cmd uint16) error {
}
func (s *Server) submitBusiness(st *connState, frame protocol.Frame) {
- ctx := reqctx.With(context.Background(), st.sess.RequestContext())
+ rc := st.sess.RequestContext()
+ if frame.RequestID != 0 {
+ rc.RequestID = strconv.FormatUint(frame.RequestID, 10)
+ } else {
+ rc.RequestID = reqctx.NewID()
+ }
+ ctx := logger.Attach(reqctx.With(context.Background(), rc), s.log)
copied := frame
err := s.pool.Submit(ctx, func() {
s.runHandler(ctx, st.sess, copied)
@@ -58,24 +75,46 @@ func (s *Server) runHandler(ctx context.Context, conn *session.Conn, frame proto
ctx, cancel := context.WithTimeout(ctx, s.handlerTimeout)
defer cancel()
resp, err := s.router.Dispatch(ctx, conn, frame)
+ var respBody []byte
if err != nil {
+ s.logAppErr(ctx, err)
+ respBody = protocol.EncodeErrLang(i18n.Parse(conn.Language()), err)
_ = conn.Send(&session.Packet{
- Command: frame.Command, SequenceID: frame.SequenceID, RequestID: frame.RequestID,
- Body: protocol.EncodeError(errorCode(err)),
+ Command: frame.Command, SequenceID: frame.SequenceID, RequestID: frame.RequestID, Body: respBody,
+ })
+ } else if resp != nil {
+ respBody = resp.Body
+ _ = conn.Send(&session.Packet{
+ Command: resp.Command, SequenceID: resp.SequenceID, RequestID: resp.RequestID, Flags: resp.Flags, Body: resp.Body,
})
- return
}
- if resp == nil {
- return
- }
- _ = conn.Send(&session.Packet{
- Command: resp.Command, SequenceID: resp.SequenceID, RequestID: resp.RequestID, Flags: resp.Flags, Body: resp.Body,
- })
+ logger.From(ctx).Info("tcp_access",
+ zap.Uint16("cmd", frame.Command),
+ zap.Uint64("frame_request_id", frame.RequestID),
+ zap.String("request_body", logger.ClipBody(frame.Body)),
+ zap.String("response_body", logger.ClipBody(respBody)),
+ )
}
func (s *Server) replyErr(st *connState, frame protocol.Frame, err error) {
- body := protocol.EncodeError(errorCode(err))
- s.writeEncoded(st, replyFrame(frame.Command, frame.SequenceID, frame.RequestID, body, 0))
+ rc := st.sess.RequestContext()
+ if frame.RequestID != 0 {
+ rc.RequestID = strconv.FormatUint(frame.RequestID, 10)
+ }
+ s.logAppErr(logger.Attach(reqctx.With(context.Background(), rc), s.log), err)
+ s.writeEncoded(st, replyFrame(frame.Command, frame.SequenceID, frame.RequestID, protocol.EncodeErrLang(i18n.Parse(st.sess.Language()), err), 0))
+}
+
+func (s *Server) logAppErr(ctx context.Context, err error) {
+ app, ok := errs.AsAppError(err)
+ if !ok || app == nil || !app.LogPrint {
+ return
+ }
+ logger.From(ctx).Error("app_error",
+ zap.String("code", app.Code),
+ zap.String("msg", app.Msg),
+ zap.String("inner_msg", app.InnerMsg),
+ )
}
func (s *Server) writeEncoded(st *connState, frame protocol.Frame) {
diff --git a/internal/transport/tcp/errors.go b/internal/transport/tcp/errors.go
index 4567237..6fc497b 100644
--- a/internal/transport/tcp/errors.go
+++ b/internal/transport/tcp/errors.go
@@ -1,35 +1,9 @@
package tcp
import (
- "errors"
-
- "spark-server/internal/pkg/errs"
"spark-server/internal/transport/tcp/protocol"
)
-func errorCode(err error) protocol.ErrorBody {
- switch {
- case err == nil:
- return protocol.ErrorBody{Code: protocol.CodeOK}
- case errors.Is(err, errs.ErrUnauthorized):
- return protocol.ErrorBody{Code: protocol.CodeUnauthorized, Message: err.Error()}
- case errors.Is(err, errs.ErrNotAuthenticated):
- return protocol.ErrorBody{Code: protocol.CodeNotAuth, Message: err.Error()}
- case errors.Is(err, errs.ErrForbidden):
- return protocol.ErrorBody{Code: protocol.CodeForbidden, Message: err.Error()}
- case errors.Is(err, errs.ErrNotFound):
- return protocol.ErrorBody{Code: protocol.CodeNotFound, Message: err.Error()}
- case errors.Is(err, errs.ErrInvalidArgument), errors.Is(err, errs.ErrInvalidFrame), errors.Is(err, errs.ErrFrameTooLarge):
- return protocol.ErrorBody{Code: protocol.CodeInvalid, Message: err.Error()}
- case errors.Is(err, errs.ErrBusy), errors.Is(err, errs.ErrOutboundDropped):
- return protocol.ErrorBody{Code: protocol.CodeBusy, Message: err.Error()}
- case errors.Is(err, errs.ErrRateLimited):
- return protocol.ErrorBody{Code: protocol.CodeRateLimited, Message: err.Error()}
- default:
- return protocol.ErrorBody{Code: protocol.CodeInternal, Message: "internal"}
- }
-}
-
func replyFrame(cmd uint16, seq uint32, req uint64, body []byte, flags uint16) protocol.Frame {
return protocol.Frame{Magic: protocol.Magic, Version: protocol.CurrentVersion, Command: cmd, Flags: flags, RequestID: req, SequenceID: seq, Body: body}
}
diff --git a/internal/transport/tcp/handlers.go b/internal/transport/tcp/handlers.go
index 251ef68..cbf07de 100644
--- a/internal/transport/tcp/handlers.go
+++ b/internal/transport/tcp/handlers.go
@@ -4,6 +4,7 @@ import (
"context"
"spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/i18n"
"spark-server/internal/transport/tcp/protocol"
"spark-server/internal/transport/tcp/session"
)
@@ -21,20 +22,20 @@ func (s *Server) handleAuth(ctx context.Context, conn *session.Conn, frame proto
if err != nil {
return nil, errs.ErrInvalidArgument
}
+ conn.SetLanguage(i18n.Parse(req.Language).String())
principal, err := s.auth.Authenticate(ctx, req.Token)
if err != nil {
return nil, err
}
- deviceID := principal.DeviceID
- if req.DeviceID != 0 {
- deviceID = req.DeviceID
+ if req.DeviceID != 0 && req.DeviceID != principal.DeviceID {
+ return nil, errs.ErrUnauthorized
}
- conn.Authenticate(principal.UserID, deviceID)
+ conn.Authenticate(principal.UserID, principal.DeviceID)
if err := s.sessions.Bind(conn); err != nil {
return nil, err
}
conn.Activate()
- body := protocol.EncodeAuthResponse(protocol.AuthResponse{UserID: principal.UserID, DeviceID: deviceID})
+ body := protocol.EncodeOK(protocol.AuthResponse{UserID: principal.UserID, DeviceID: principal.DeviceID})
return &protocol.Frame{
Version: protocol.CurrentVersion, Command: protocol.CmdAuth,
RequestID: frame.RequestID, SequenceID: frame.SequenceID, Body: body,
diff --git a/internal/transport/tcp/protocol/codes.go b/internal/transport/tcp/protocol/codes.go
index 50a46f6..a3bbc89 100644
--- a/internal/transport/tcp/protocol/codes.go
+++ b/internal/transport/tcp/protocol/codes.go
@@ -1,16 +1,16 @@
package protocol
const (
- CodeOK int32 = 0
- CodeUnauthorized int32 = 40101
- CodeForbidden int32 = 40301
- CodeNotFound int32 = 40401
- CodeInvalid int32 = 40001
- CodeBusy int32 = 42901
- CodeRateLimited int32 = 42902
- CodeInternal int32 = 50001
- CodeNotAuth int32 = 40102
- CodeOutboundDrop int32 = 42903
+ CodeOK = "0"
+ CodeUnauthorized = "40101"
+ CodeForbidden = "40301"
+ CodeNotFound = "40401"
+ CodeInvalid = "40001"
+ CodeBusy = "42901"
+ CodeRateLimited = "42902"
+ CodeInternal = "50001"
+ CodeNotAuth = "40102"
+ CodeOutboundDrop = "42903"
)
const CurrentVersion uint8 = 1
diff --git a/internal/transport/tcp/protocol/envelope.go b/internal/transport/tcp/protocol/envelope.go
new file mode 100644
index 0000000..4f49535
--- /dev/null
+++ b/internal/transport/tcp/protocol/envelope.go
@@ -0,0 +1,22 @@
+package protocol
+
+import (
+ "spark-server/internal/pkg/i18n"
+ "spark-server/internal/pkg/reply"
+)
+
+func EncodeOK(data any) []byte {
+ return reply.MustMarshal(reply.OK(data))
+}
+
+func EncodeErr(err error) []byte {
+ return EncodeErrLang(i18n.Default, err)
+}
+
+func EncodeErrLang(lang i18n.Lang, err error) []byte {
+ return reply.MustMarshal(reply.FromErrorLang(lang, err))
+}
+
+func DecodeEnvelope(body []byte) (reply.Envelope, error) {
+ return reply.Unmarshal(body)
+}
diff --git a/internal/transport/tcp/protocol/payload.go b/internal/transport/tcp/protocol/payload.go
index 46244cf..36302f8 100644
--- a/internal/transport/tcp/protocol/payload.go
+++ b/internal/transport/tcp/protocol/payload.go
@@ -1,146 +1,69 @@
package protocol
import (
- "spark-server/internal/pkg/errs"
+ "encoding/json"
- "google.golang.org/protobuf/encoding/protowire"
+ "spark-server/internal/pkg/errs"
+ "spark-server/internal/pkg/reply"
)
type AuthRequest struct {
- Token string
- DeviceID uint64
- AppVersion string
- Language string
+ Token string `json:"token"`
+ DeviceID uint64 `json:"device_id"`
+ AppVersion string `json:"app_version"`
+ Language string `json:"language"`
}
type AuthResponse struct {
- UserID int64
- DeviceID uint64
+ UserID int64 `json:"user_id"`
+ DeviceID uint64 `json:"device_id"`
+}
+
+type MsgIDBody struct {
+ MsgID int64 `json:"msg_id"`
+}
+
+type KickBody struct {
+ Reason string `json:"reason"`
}
type ErrorBody struct {
- Code int32
+ Code string
Message string
}
func EncodeAuthRequest(a AuthRequest) []byte {
- out := appendStringField(nil, 1, a.Token)
- out = appendVarintField(out, 2, a.DeviceID)
- out = appendStringField(out, 3, a.AppVersion)
- out = appendStringField(out, 4, a.Language)
- return out
+ b, _ := json.Marshal(a)
+ return b
}
func DecodeAuthRequest(body []byte) (AuthRequest, error) {
var req AuthRequest
- for len(body) > 0 {
- num, typ, tagLen := protowire.ConsumeTag(body)
- if tagLen < 0 {
- return AuthRequest{}, errs.ErrInvalidFrame
- }
- body = body[tagLen:]
- val, n, err := consumeField(typ, body)
- if err != nil {
- return AuthRequest{}, err
- }
- body = body[n:]
- assignAuth(&req, num, typ, val)
+ if err := json.Unmarshal(body, &req); err != nil {
+ return AuthRequest{}, errs.ErrInvalidFrame
+ }
+ if req.Token == "" {
+ return AuthRequest{}, errs.ErrInvalidArgument
}
return req, nil
}
-func assignAuth(req *AuthRequest, num protowire.Number, typ protowire.Type, val []byte) {
- switch {
- case num == 1 && typ == protowire.BytesType:
- req.Token = string(val)
- case num == 2 && typ == protowire.VarintType:
- v, _ := protowire.ConsumeVarint(val)
- req.DeviceID = v
- case num == 3 && typ == protowire.BytesType:
- req.AppVersion = string(val)
- case num == 4 && typ == protowire.BytesType:
- req.Language = string(val)
- }
-}
-
-func EncodeAuthResponse(a AuthResponse) []byte {
- out := appendVarintField(nil, 1, uint64(a.UserID))
- return appendVarintField(out, 2, a.DeviceID)
-}
-
func EncodeError(e ErrorBody) []byte {
- out := appendVarintField(nil, 1, uint64(e.Code))
- return appendStringField(out, 2, e.Message)
+ return reply.MustMarshal(reply.Fail(e.Code, e.Message))
}
-// DecodeError reads a protobuf-wire error payload.
func DecodeError(body []byte) (ErrorBody, error) {
- var out ErrorBody
- for len(body) > 0 {
- num, typ, tagLen := protowire.ConsumeTag(body)
- if tagLen < 0 {
- return ErrorBody{}, errs.ErrInvalidFrame
- }
- body = body[tagLen:]
- val, n, err := consumeField(typ, body)
- if err != nil {
- return ErrorBody{}, err
- }
- body = body[n:]
- if num == 1 && typ == protowire.VarintType {
- v, _ := protowire.ConsumeVarint(val)
- out.Code = int32(v)
- }
- if num == 2 && typ == protowire.BytesType {
- out.Message = string(val)
- }
+ env, err := reply.Unmarshal(body)
+ if err != nil {
+ return ErrorBody{}, err
}
- return out, nil
+ return ErrorBody{Code: env.Code, Message: env.Msg}, nil
}
func EncodeMsgID(id int64) []byte {
- return appendVarintField(nil, 1, uint64(id))
+ return EncodeOK(MsgIDBody{MsgID: id})
}
func EncodeKick(reason string) []byte {
- return appendStringField(nil, 1, reason)
-}
-
-func appendStringField(out []byte, field protowire.Number, s string) []byte {
- if s == "" {
- return out
- }
- out = protowire.AppendVarint(out, protowire.EncodeTag(field, protowire.BytesType))
- return protowire.AppendString(out, s)
-}
-
-func appendVarintField(out []byte, field protowire.Number, v uint64) []byte {
- if v == 0 {
- return out
- }
- out = protowire.AppendVarint(out, protowire.EncodeTag(field, protowire.VarintType))
- return protowire.AppendVarint(out, v)
-}
-
-func consumeField(typ protowire.Type, body []byte) ([]byte, int, error) {
- switch typ {
- case protowire.VarintType:
- v, n := protowire.ConsumeVarint(body)
- if n < 0 {
- return nil, 0, errs.ErrInvalidFrame
- }
- return protowire.AppendVarint(nil, v), n, nil
- case protowire.BytesType:
- v, n := protowire.ConsumeBytes(body)
- if n < 0 {
- return nil, 0, errs.ErrInvalidFrame
- }
- return append([]byte(nil), v...), n, nil
- default:
- n := protowire.ConsumeFieldValue(1, typ, body)
- if n < 0 {
- return nil, 0, errs.ErrInvalidFrame
- }
- return nil, n, nil
- }
+ return EncodeOK(KickBody{Reason: reason})
}
diff --git a/internal/transport/tcp/protocol/payload_test.go b/internal/transport/tcp/protocol/payload_test.go
new file mode 100644
index 0000000..6fb0c31
--- /dev/null
+++ b/internal/transport/tcp/protocol/payload_test.go
@@ -0,0 +1,20 @@
+package protocol
+
+import "testing"
+
+func TestAuthRequestJSONRoundTrip(t *testing.T) {
+ raw := EncodeAuthRequest(AuthRequest{Token: "abc", DeviceID: 7, AppVersion: "1.0", Language: "zh-CN"})
+ got, err := DecodeAuthRequest(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Token != "abc" || got.DeviceID != 7 || got.Language != "zh-CN" {
+ t.Fatalf("%+v", got)
+ }
+ if _, err := DecodeAuthRequest([]byte(`{"device_id":1}`)); err == nil {
+ t.Fatal("empty token")
+ }
+ if _, err := DecodeAuthRequest([]byte("not-json")); err == nil {
+ t.Fatal("garbage")
+ }
+}
diff --git a/internal/transport/tcp/server.go b/internal/transport/tcp/server.go
index c77abb9..64c1a5f 100644
--- a/internal/transport/tcp/server.go
+++ b/internal/transport/tcp/server.go
@@ -3,15 +3,19 @@ package tcp
import (
"context"
"crypto/tls"
- "log/slog"
+ "fmt"
+ "net"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/panjf2000/gnet/v2"
+ "go.uber.org/zap"
- "spark-server/internal/module/auth"
+ authlogic "spark-server/internal/module/auth/logic"
+ "spark-server/internal/pkg/ratelimit"
+ "spark-server/internal/pkg/reqctx"
"spark-server/internal/transport/tcp/session"
)
@@ -42,7 +46,7 @@ type MsgIDGenerator interface {
// Server is the gnet event engine. OnTraffic must not call MySQL, Redis, or deliver fanout.
type Server struct {
gnet.BuiltinEventEngine
- log *slog.Logger
+ log *zap.Logger
pool TaskPool
sessions session.Manager
eng gnet.Engine
@@ -55,14 +59,16 @@ type Server struct {
ratePerSec int
compressMin int
handlerTimeout time.Duration
- auth auth.Service
+ auth authlogic.Service
ids MsgIDGenerator
router *Router
metrics Metrics
+ authLimit *ratelimit.Limiter
conns sync.Map
seq atomic.Uint64
ready chan struct{}
readyOnce sync.Once
+ listening atomic.Bool
}
// Options configure the TCP listener. TLS is terminated at Nginx in production.
@@ -80,16 +86,20 @@ type Options struct {
RatePerSec int
CompressMin int
HandlerTimeout time.Duration
- Auth auth.Service
+ Auth authlogic.Service
IDs MsgIDGenerator
Metrics Metrics
+ AuthLimiter *ratelimit.Limiter
}
// NewServer builds a gnet IM listener. Call Start to bind.
-func NewServer(log *slog.Logger, pool TaskPool, sessions session.Manager, o *Options) *Server {
+func NewServer(log *zap.Logger, pool TaskPool, sessions session.Manager, o *Options) *Server {
if o == nil {
o = &Options{}
}
+ if log == nil {
+ log = zap.NewNop()
+ }
applyTCPDefaults(o)
s := &Server{
log: log,
@@ -106,6 +116,7 @@ func NewServer(log *slog.Logger, pool TaskPool, sessions session.Manager, o *Opt
auth: o.Auth,
ids: o.IDs,
metrics: o.Metrics,
+ authLimit: o.AuthLimiter,
router: NewRouter(),
ready: make(chan struct{}),
}
@@ -114,7 +125,7 @@ func NewServer(log *slog.Logger, pool TaskPool, sessions session.Manager, o *Opt
}
s.registerDefaultHandlers()
if o.TLS != nil {
- log.Info("tls cert loaded; terminate at nginx or wrap listener (gnet v2 has no native TLS)")
+ log.Info("tcp tls cert ignored; terminate IM TLS at nginx (gnet v2 has no native TLS)")
}
s.opts = gnetOptions(o)
return s
@@ -164,8 +175,9 @@ func (s *Server) Router() *Router { return s.router }
func (s *Server) OnBoot(eng gnet.Engine) gnet.Action {
s.eng = eng
- s.log.Info("tcp listening", "addr", s.addr)
- s.readyOnce.Do(func() { close(s.ready) })
+ s.log.Info("tcp listening", zap.String("addr", s.addr))
+ s.listening.Store(true)
+ s.signalReady()
return gnet.None
}
@@ -227,10 +239,33 @@ func (s *Server) OnTraffic(c gnet.Conn) gnet.Action {
}
func (s *Server) Start() error {
- return gnet.Run(s, "tcp://"+s.addr, s.opts...)
+ err := gnet.Run(s, "tcp://"+s.addr, s.opts...)
+ s.signalReady()
+ return err
+}
+
+func (s *Server) WaitReady(ctx context.Context) error {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-s.ready:
+ if s.listening.Load() {
+ return nil
+ }
+ return fmt.Errorf("tcp not listening")
+ }
+}
+
+func (s *Server) signalReady() {
+ s.readyOnce.Do(func() { close(s.ready) })
}
func (s *Server) Stop(ctx context.Context) error {
+ if !s.listening.Load() {
+ s.signalReady()
+ return nil
+ }
+ s.listening.Store(false)
return s.eng.Stop(ctx)
}
@@ -241,6 +276,7 @@ func (s *Server) attach(c gnet.Conn) {
remote = addr.String()
}
sess := session.NewConn(id, remote, s.outboundSize)
+ sess.SetTraceID(reqctx.NewID())
if !s.proxyProtocol {
sess.MarkProxySkipped()
}
@@ -250,3 +286,18 @@ func (s *Server) attach(c gnet.Conn) {
c.SetContext(st)
s.conns.Store(id, st)
}
+
+func (s *Server) allowAuthIP(ip string) bool {
+ if s.authLimit == nil {
+ return true
+ }
+ host := ip
+ if h, _, err := net.SplitHostPort(ip); err == nil {
+ host = h
+ }
+ if host == "" {
+ s.metrics.Inc("realip_missing_total")
+ host = "unknown"
+ }
+ return s.authLimit.Allow(host)
+}
diff --git a/internal/transport/tcp/server_test.go b/internal/transport/tcp/server_test.go
index cdcc0d1..c1e0323 100644
--- a/internal/transport/tcp/server_test.go
+++ b/internal/transport/tcp/server_test.go
@@ -4,13 +4,13 @@ import (
"context"
"encoding/binary"
"io"
- "log/slog"
"net"
"testing"
"time"
"spark-server/internal/infrastructure/id"
- "spark-server/internal/module/auth"
+ "spark-server/internal/infrastructure/logger"
+ authlogic "spark-server/internal/module/auth/logic"
"spark-server/internal/transport/tcp/protocol"
"spark-server/internal/transport/tcp/session"
)
@@ -32,7 +32,7 @@ func TestAuthGateAndGenerateMsgID(t *testing.T) {
t.Fatal(err)
}
srv := startTCP(t, &Options{
- Auth: auth.Static{Token: "secret", UserID: 42, DeviceID: 7},
+ Auth: authlogic.Static{Token: "secret", UserID: 42, DeviceID: 7},
IDs: ids,
})
c := dial(t, srv.addr)
@@ -55,8 +55,25 @@ func TestAuthGateAndGenerateMsgID(t *testing.T) {
Body: protocol.EncodeAuthRequest(protocol.AuthRequest{Token: "secret", DeviceID: 7}),
})
authed := readFrame(t, c)
- if authed.Command != protocol.CmdAuth || len(authed.Body) == 0 {
- t.Fatalf("auth reply %+v", authed)
+ env, err := protocol.DecodeEnvelope(authed.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if authed.Command != protocol.CmdAuth || env.Code != protocol.CodeOK {
+ t.Fatalf("auth reply %+v env=%+v", authed, env)
+ }
+
+ writeFrame(t, c, protocol.Frame{
+ Version: protocol.CurrentVersion, Command: protocol.CmdAuth, RequestID: 8, SequenceID: 8,
+ Body: protocol.EncodeAuthRequest(protocol.AuthRequest{Token: "secret", DeviceID: 99, Language: "en"}),
+ })
+ mismatch := readFrame(t, c)
+ eb2, err := protocol.DecodeError(mismatch.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if eb2.Code != protocol.CodeUnauthorized || eb2.Message != "unauthorized" {
+ t.Fatalf("want device mismatch unauthorized, got %+v", eb2)
}
writeFrame(t, c, protocol.Frame{
@@ -68,6 +85,48 @@ func TestAuthGateAndGenerateMsgID(t *testing.T) {
}
}
+func TestAuthJWTSameService(t *testing.T) {
+ ids, err := id.New(1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ j, err := authlogic.NewJWT(&authlogic.Options{Secret: "test-secret-test-secret", Issuer: "spark-im", TTL: time.Hour})
+ if err != nil {
+ t.Fatal(err)
+ }
+ tok, err := j.Issue(context.Background(), 42, 7)
+ if err != nil {
+ t.Fatal(err)
+ }
+ srv := startTCP(t, &Options{Auth: j, IDs: ids})
+ c := dial(t, srv.addr)
+ defer c.Close()
+ writeFrame(t, c, protocol.Frame{
+ Version: protocol.CurrentVersion, Command: protocol.CmdAuth, SequenceID: 1,
+ Body: protocol.EncodeAuthRequest(protocol.AuthRequest{Token: tok.AccessToken, DeviceID: 7}),
+ })
+ authed := readFrame(t, c)
+ env, err := protocol.DecodeEnvelope(authed.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if authed.Command != protocol.CmdAuth || env.Code != protocol.CodeOK {
+ t.Fatalf("jwt auth %+v env=%+v", authed, env)
+ }
+ writeFrame(t, c, protocol.Frame{
+ Version: protocol.CurrentVersion, Command: protocol.CmdAuth, SequenceID: 2,
+ Body: protocol.EncodeAuthRequest(protocol.AuthRequest{Token: tok.AccessToken, DeviceID: 99}),
+ })
+ mismatch := readFrame(t, c)
+ eb, err := protocol.DecodeError(mismatch.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if eb.Code != protocol.CodeUnauthorized {
+ t.Fatalf("jwt device mismatch %+v", eb)
+ }
+}
+
func TestProxyProtocolThenHeartbeat(t *testing.T) {
ids, err := id.New(1)
if err != nil {
@@ -75,7 +134,7 @@ func TestProxyProtocolThenHeartbeat(t *testing.T) {
}
srv := startTCP(t, &Options{
ProxyProtocol: true,
- Auth: auth.Static{Token: "secret", UserID: 42, DeviceID: 7},
+ Auth: authlogic.Static{Token: "secret", UserID: 42, DeviceID: 7},
IDs: ids,
})
c := dial(t, srv.addr)
@@ -122,7 +181,7 @@ func startTCP(t *testing.T, o *Options) *Server {
o.IdleTimeout = time.Hour
}
o.KeepAlive = 30 * time.Second
- log := slog.New(slog.NewTextHandler(io.Discard, nil))
+ log := logger.Nop()
pool := NewTaskPool(4, 32)
t.Cleanup(pool.Close)
s := NewServer(log, pool, session.NewManager(), o)
diff --git a/internal/transport/tcp/session/conn.go b/internal/transport/tcp/session/conn.go
index 6e7be06..a7c150f 100644
--- a/internal/transport/tcp/session/conn.go
+++ b/internal/transport/tcp/session/conn.go
@@ -23,6 +23,7 @@ type Conn struct {
userID atomic.Int64
deviceID atomic.Uint64
realIP atomic.Value // string
+ language atomic.Value // string
remote string
state atomic.Uint32
lastActive atomic.Int64
@@ -30,6 +31,7 @@ type Conn struct {
proxyDone atomic.Bool
closed atomic.Bool
+ traceID string
outbound chan *Packet
write func([]byte) error
closeFn func() error
@@ -48,6 +50,7 @@ func NewConn(id, remote string, outboundSize int) *Conn {
c.state.Store(StateConnected)
c.lastActive.Store(time.Now().UnixNano())
c.realIP.Store("")
+ c.language.Store("")
return c
}
@@ -61,6 +64,9 @@ func (c *Conn) SetWake(wake func()) {
c.wake = wake
}
+func (c *Conn) SetTraceID(id string) { c.traceID = id }
+func (c *Conn) TraceID() string { return c.traceID }
+
func (c *Conn) ID() string { return c.id }
func (c *Conn) UserID() int64 { return c.userID.Load() }
func (c *Conn) DeviceID() uint64 { return c.deviceID.Load() }
@@ -80,6 +86,15 @@ func (c *Conn) SetRealIP(ip string) {
c.proxyDone.Store(true)
}
+func (c *Conn) Language() string {
+ v, _ := c.language.Load().(string)
+ return v
+}
+
+func (c *Conn) SetLanguage(lang string) {
+ c.language.Store(lang)
+}
+
func (c *Conn) MarkProxySkipped() { c.proxyDone.Store(true) }
func (c *Conn) ProxyDone() bool { return c.proxyDone.Load() }
@@ -193,10 +208,12 @@ func (c *Conn) DrainOutbound(limit int) []*Packet {
func (c *Conn) RequestContext() *reqctx.RequestContext {
return &reqctx.RequestContext{
+ TraceID: c.traceID,
UserID: c.UserID(),
DeviceID: c.DeviceID(),
ConnectionID: c.id,
RemoteAddr: c.remote,
RealIP: c.RealIP(),
+ Language: c.Language(),
}
}
diff --git a/internal/transport/tcp/session/conn_test.go b/internal/transport/tcp/session/conn_test.go
index 1d18cae..09564ee 100644
--- a/internal/transport/tcp/session/conn_test.go
+++ b/internal/transport/tcp/session/conn_test.go
@@ -53,3 +53,19 @@ func TestKickSameDevice(t *testing.T) {
t.Fatalf("sessions %+v", sessions)
}
}
+
+func TestKickDevice(t *testing.T) {
+ m := NewManager()
+ c := NewConn("s1", "10.0.0.1:1", 8)
+ c.Authenticate(3, 4)
+ if err := m.Bind(c); err != nil {
+ t.Fatal(err)
+ }
+ m.KickDevice(3, 4)
+ if c.State() != StateClosed {
+ t.Fatalf("state %d", c.State())
+ }
+ if len(m.GetUserSessions(3)) != 0 {
+ t.Fatal("expected empty")
+ }
+}
diff --git a/internal/transport/tcp/session/kick.go b/internal/transport/tcp/session/kick.go
index a8b8929..935fba4 100644
--- a/internal/transport/tcp/session/kick.go
+++ b/internal/transport/tcp/session/kick.go
@@ -1,6 +1,7 @@
package session
import (
+ "spark-server/internal/pkg/i18n"
"spark-server/internal/transport/tcp/protocol"
)
@@ -20,12 +21,35 @@ func (m *memManager) kickSameDevice(incoming Session) {
if existing.DeviceID() != incoming.DeviceID() || existing.ID() == incoming.ID() {
continue
}
+ reason := i18n.T(i18n.Parse(existing.Language()), i18n.NotifyKickReplaced, nil)
_ = existing.Send(&Packet{
Command: protocol.CmdKick,
- Body: protocol.EncodeKick("replaced by new connection"),
+ Body: protocol.EncodeKick(reason),
})
_ = existing.Close()
delete(m.byID, existing.ID())
delete(userMap, existing.ID())
}
}
+
+func (m *memManager) KickDevice(userID int64, deviceID uint64) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ userMap := m.byUser[userID]
+ if userMap == nil {
+ return
+ }
+ for id, existing := range userMap {
+ if existing.DeviceID() != deviceID {
+ continue
+ }
+ reason := i18n.T(i18n.Parse(existing.Language()), i18n.NotifyKickLogout, nil)
+ _ = existing.Send(&Packet{
+ Command: protocol.CmdKick,
+ Body: protocol.EncodeKick(reason),
+ })
+ _ = existing.Close()
+ delete(m.byID, id)
+ delete(userMap, id)
+ }
+}
diff --git a/internal/transport/tcp/session/manager.go b/internal/transport/tcp/session/manager.go
index 5a9803e..c16f7ab 100644
--- a/internal/transport/tcp/session/manager.go
+++ b/internal/transport/tcp/session/manager.go
@@ -19,6 +19,7 @@ type Session interface {
UserID() int64
DeviceID() uint64
RealIP() string
+ Language() string
Send(packet *Packet) error
Close() error
}
@@ -28,6 +29,7 @@ type Manager interface {
Unbind(sessionID string) error
Get(sessionID string) (Session, bool)
GetUserSessions(userID int64) []Session
+ KickDevice(userID int64, deviceID uint64)
SendToUserExceptDevice(ctx context.Context, userID int64, exceptDeviceID uint64, packet *Packet) error
}
diff --git a/internal/transport/tcp/taskpool.go b/internal/transport/tcp/taskpool.go
index 56f05c3..c3f61b7 100644
--- a/internal/transport/tcp/taskpool.go
+++ b/internal/transport/tcp/taskpool.go
@@ -3,6 +3,7 @@ package tcp
import (
"context"
"sync"
+ "sync/atomic"
"spark-server/internal/pkg/errs"
)
@@ -11,6 +12,7 @@ import (
type TaskPool interface {
Submit(ctx context.Context, task func()) error
Close()
+ Closed() bool
}
type pool struct {
@@ -18,16 +20,22 @@ type pool struct {
workers int
wg sync.WaitGroup
once sync.Once
+ closed atomic.Bool
+ onPanic func(any)
}
func NewTaskPool(workers, queue int) TaskPool {
+ return NewTaskPoolRecover(workers, queue, nil)
+}
+
+func NewTaskPoolRecover(workers, queue int, onPanic func(any)) TaskPool {
if workers < 1 {
workers = 1
}
if queue < 1 {
queue = 1
}
- p := &pool{tasks: make(chan func(), queue), workers: workers}
+ p := &pool{tasks: make(chan func(), queue), workers: workers, onPanic: onPanic}
for i := 0; i < workers; i++ {
p.wg.Add(1)
go p.loop()
@@ -38,12 +46,22 @@ func NewTaskPool(workers, queue int) TaskPool {
func (p *pool) loop() {
defer p.wg.Done()
for task := range p.tasks {
- if task != nil {
- task()
- }
+ p.run(task)
}
}
+func (p *pool) run(task func()) {
+ if task == nil {
+ return
+ }
+ defer func() {
+ if rec := recover(); rec != nil && p.onPanic != nil {
+ p.onPanic(rec)
+ }
+ }()
+ task()
+}
+
func (p *pool) Submit(ctx context.Context, task func()) error {
if task == nil {
return errs.ErrInvalidArgument
@@ -60,7 +78,12 @@ func (p *pool) Submit(ctx context.Context, task func()) error {
func (p *pool) Close() {
p.once.Do(func() {
+ p.closed.Store(true)
close(p.tasks)
p.wg.Wait()
})
}
+
+func (p *pool) Closed() bool {
+ return p.closed.Load()
+}
diff --git a/internal/transport/tcp/taskpool_test.go b/internal/transport/tcp/taskpool_test.go
index 72eaa52..12a1a3f 100644
--- a/internal/transport/tcp/taskpool_test.go
+++ b/internal/transport/tcp/taskpool_test.go
@@ -23,6 +23,27 @@ func TestTaskPoolRunsJob(t *testing.T) {
}
}
+func TestTaskPoolRecoversPanic(t *testing.T) {
+ p := NewTaskPool(1, 4)
+ defer p.Close()
+ if err := p.Submit(context.Background(), func() { panic("boom") }); err != nil {
+ t.Fatal(err)
+ }
+ done := make(chan struct{})
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ if err := p.Submit(context.Background(), func() { close(done) }); err == nil {
+ break
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("worker lost after panic")
+ }
+}
+
func TestTaskPoolBusy(t *testing.T) {
p := NewTaskPool(1, 1)
defer p.Close()
@@ -41,3 +62,11 @@ func TestTaskPoolBusy(t *testing.T) {
t.Fatalf("want busy, got %v", err)
}
}
+
+func TestTaskPoolClosed(t *testing.T) {
+ p := NewTaskPool(1, 1)
+ p.Close()
+ if !p.Closed() {
+ t.Fatal("closed")
+ }
+}
diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql
index c228095..db65a27 100644
--- a/migrations/0001_init.sql
+++ b/migrations/0001_init.sql
@@ -1,62 +1,62 @@
-- Spark IM V3 initial schema. Message body lives in MySQL; dialog list lives in Redis.
CREATE TABLE IF NOT EXISTS conversations (
- id BIGINT PRIMARY KEY,
- type TINYINT NOT NULL,
- last_msg_id BIGINT NOT NULL DEFAULT 0,
- last_msg_at DATETIME(3) NOT NULL,
- created_at DATETIME(3) NOT NULL
-);
+ id BIGINT PRIMARY KEY COMMENT 'Snowflake 会话 id,时间有序主键',
+ type TINYINT NOT NULL COMMENT '会话类型:1=单聊 2=群聊',
+ last_msg_id BIGINT NOT NULL DEFAULT 0 COMMENT '最新消息 Snowflake id,0 表示尚无消息',
+ last_msg_at DATETIME(3) NOT NULL COMMENT '最新消息时间(毫秒精度)',
+ created_at DATETIME(3) NOT NULL COMMENT '会话创建时间'
+) COMMENT='会话。单聊/群聊共用一张表;列表与未读在 Redis,不在此表热更新';
CREATE TABLE IF NOT EXISTS p2p_conversations (
- user_small BIGINT NOT NULL,
- user_big BIGINT NOT NULL,
- conversation_id BIGINT NOT NULL,
+ user_small BIGINT NOT NULL COMMENT '两人中较小的 user_id,保证同一对用户只有一行',
+ user_big BIGINT NOT NULL COMMENT '两人中较大的 user_id',
+ conversation_id BIGINT NOT NULL COMMENT '对应 conversations.id',
PRIMARY KEY (user_small, user_big),
UNIQUE KEY uk_conversation (conversation_id)
-);
+) COMMENT='单聊唯一索引。按 (较小 uid, 较大 uid) 定位会话,避免 A-B / B-A 两条记录';
CREATE TABLE IF NOT EXISTS conversation_members (
- conversation_id BIGINT NOT NULL,
- user_id BIGINT NOT NULL,
- last_read_msg_id BIGINT NOT NULL DEFAULT 0,
- joined_at DATETIME(3) NOT NULL,
- muted TINYINT NOT NULL DEFAULT 0,
+ conversation_id BIGINT NOT NULL COMMENT '会话 id',
+ user_id BIGINT NOT NULL COMMENT '成员 user_id',
+ last_read_msg_id BIGINT NOT NULL DEFAULT 0 COMMENT '该成员已读到的最大消息 id,未读真相;Redis 丢了可从此重建',
+ joined_at DATETIME(3) NOT NULL COMMENT '加入会话时间',
+ muted TINYINT NOT NULL DEFAULT 0 COMMENT '0=未静音 1=免打扰',
PRIMARY KEY (conversation_id, user_id),
KEY idx_user_id (user_id)
-);
+) COMMENT='会话成员。热路径会话列表不扫此表;last_read_msg_id 是已读持久化';
CREATE TABLE IF NOT EXISTS 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,不存媒体二进制';
CREATE TABLE IF NOT EXISTS mailbox_events (
- id BIGINT PRIMARY KEY,
- user_id BIGINT NOT NULL,
- device_id BIGINT NOT NULL,
- seq BIGINT NOT NULL,
- event_type SMALLINT NOT NULL,
- conversation_id BIGINT NULL,
- payload VARBINARY(4096),
- created_at DATETIME(3) NOT NULL,
+ id BIGINT PRIMARY KEY COMMENT 'Snowflake 事件 id',
+ user_id BIGINT NOT NULL COMMENT '接收用户',
+ device_id BIGINT NOT NULL COMMENT '目标设备;0 表示该用户全部设备',
+ seq BIGINT NOT NULL COMMENT '该 user+device 上单调递增的邮箱序号',
+ event_type SMALLINT NOT NULL COMMENT '事件类型:撤回/踢人/成员变更/好友/漏推消息等,不含成功在线推送的新消息',
+ conversation_id BIGINT NULL COMMENT '相关会话,可空',
+ payload VARBINARY(4096) COMMENT '控制事件载荷(会话 id+消息 id 等),不含消息正文',
+ created_at DATETIME(3) NOT NULL COMMENT '写入时间',
UNIQUE KEY uk_user_device_seq (user_id, device_id, seq),
KEY idx_user_device (user_id, device_id, seq)
-);
+) COMMENT='失败邮箱与控制事件。成功在线推送的新消息不写此表';
CREATE TABLE IF NOT EXISTS device_sync_cursors (
- user_id BIGINT NOT NULL,
- device_id BIGINT NOT NULL,
- mailbox_seq BIGINT NOT NULL DEFAULT 0,
- dialog_score BIGINT NOT NULL DEFAULT 0,
- updated_at DATETIME(3) NOT NULL,
+ user_id BIGINT NOT NULL COMMENT '用户 id',
+ device_id BIGINT NOT NULL COMMENT '设备 id',
+ mailbox_seq BIGINT NOT NULL DEFAULT 0 COMMENT '该设备已同步到的 mailbox_events.seq',
+ dialog_score BIGINT NOT NULL DEFAULT 0 COMMENT '会话列表 Redis ZSET 游标',
+ updated_at DATETIME(3) NOT NULL COMMENT '游标更新时间',
PRIMARY KEY (user_id, device_id)
-);
+) COMMENT='每设备同步游标。mailbox_seq 对邮箱,dialog_score 对会话列表';
diff --git a/migrations/0002_users.sql b/migrations/0002_users.sql
new file mode 100644
index 0000000..27a6b17
--- /dev/null
+++ b/migrations/0002_users.sql
@@ -0,0 +1,41 @@
+-- Users: Snowflake PK (not AUTO_INCREMENT), public spark_id, email map, login devices.
+
+CREATE TABLE IF NOT EXISTS users (
+ id BIGINT PRIMARY KEY COMMENT 'Snowflake 用户 id(uid),时间有序,非自增',
+ spark_id VARCHAR(32) NOT NULL COMMENT '对外展示的账号,用户可改,全局唯一',
+ nickname VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称',
+ avatar VARCHAR(512) NOT NULL DEFAULT '' COMMENT '头像对象存储 key 或 URL,不存图片二进制',
+ qrcode VARCHAR(512) NOT NULL DEFAULT '' COMMENT '个人二维码对象存储 key 或 URL,不存图片二进制',
+ birthday DATE NULL COMMENT '生日,未填为空',
+ gender TINYINT NOT NULL DEFAULT 0 COMMENT '0=未知 1=男 2=女',
+ status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 0=禁用',
+ last_login_at DATETIME(3) NULL COMMENT '最近一次登录时间',
+ last_active_at DATETIME(3) NULL COMMENT '最近一次活跃时间(登录、心跳、发消息等)',
+ created_at DATETIME(3) NOT NULL COMMENT '注册时间(create_time)',
+ updated_at DATETIME(3) NOT NULL COMMENT '资料最近更新时间(update_time)',
+ UNIQUE KEY uk_spark_id (spark_id)
+) COMMENT='用户账号。主键 Snowflake 即 uid;登录身份在 user_emails,设备在 user_devices';
+
+CREATE TABLE IF NOT EXISTS user_emails (
+ email VARCHAR(255) NOT NULL COMMENT '规范化后的小写邮箱,登录账号',
+ user_id BIGINT NOT NULL COMMENT '绑定的 users.id,一对一',
+ created_at DATETIME(3) NOT NULL COMMENT '绑定时间',
+ PRIMARY KEY (email),
+ UNIQUE KEY uk_user_id (user_id)
+) COMMENT='邮箱与用户的一对一映射。登录用邮箱查此表再读 users';
+
+CREATE TABLE IF NOT EXISTS user_devices (
+ id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增,仅内部用;业务唯一键是 (user_id, device_id)',
+ user_id BIGINT NOT NULL COMMENT '所属用户',
+ device_id BIGINT UNSIGNED NOT NULL COMMENT '客户端设备 id,写入 JWT did',
+ platform VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'ios/android/web 等',
+ device_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '设备展示名',
+ app_version VARCHAR(32) NOT NULL DEFAULT '' COMMENT '最近一次登录的客户端版本',
+ language VARCHAR(16) NOT NULL DEFAULT '' COMMENT '最近一次登录语言,如 zh-CN',
+ last_ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT '最近一次登录的 PROXY RealIP,禁止用环回地址',
+ last_login_at DATETIME(3) NOT NULL COMMENT '最近一次登录时间',
+ created_at DATETIME(3) NOT NULL COMMENT '该设备首次登录时间',
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_user_device (user_id, device_id),
+ KEY idx_user_id (user_id)
+) COMMENT='登录过的设备。同 user+device 覆盖更新;踢人按 device_id';
diff --git a/migrations/0003_table_comments.sql b/migrations/0003_table_comments.sql
new file mode 100644
index 0000000..13f5137
--- /dev/null
+++ b/migrations/0003_table_comments.sql
@@ -0,0 +1,82 @@
+-- Add table/column comments for databases created before 0001/0002 carried COMMENT clauses.
+
+ALTER TABLE conversations COMMENT = '会话。单聊/群聊共用一张表;列表与未读在 Redis,不在此表热更新';
+ALTER TABLE conversations
+ MODIFY COLUMN id BIGINT NOT NULL COMMENT 'Snowflake 会话 id,时间有序主键',
+ MODIFY COLUMN type TINYINT NOT NULL COMMENT '会话类型:1=单聊 2=群聊',
+ MODIFY COLUMN last_msg_id BIGINT NOT NULL DEFAULT 0 COMMENT '最新消息 Snowflake id,0 表示尚无消息',
+ MODIFY COLUMN last_msg_at DATETIME(3) NOT NULL COMMENT '最新消息时间(毫秒精度)',
+ MODIFY COLUMN created_at DATETIME(3) NOT NULL COMMENT '会话创建时间';
+
+ALTER TABLE p2p_conversations COMMENT = '单聊唯一索引。按 (较小 uid, 较大 uid) 定位会话,避免 A-B / B-A 两条记录';
+ALTER TABLE p2p_conversations
+ MODIFY COLUMN user_small BIGINT NOT NULL COMMENT '两人中较小的 user_id,保证同一对用户只有一行',
+ MODIFY COLUMN user_big BIGINT NOT NULL COMMENT '两人中较大的 user_id',
+ MODIFY COLUMN conversation_id BIGINT NOT NULL COMMENT '对应 conversations.id';
+
+ALTER TABLE conversation_members COMMENT = '会话成员。热路径会话列表不扫此表;last_read_msg_id 是已读持久化';
+ALTER TABLE conversation_members
+ MODIFY COLUMN conversation_id BIGINT NOT NULL COMMENT '会话 id',
+ MODIFY COLUMN user_id BIGINT NOT NULL COMMENT '成员 user_id',
+ MODIFY COLUMN last_read_msg_id BIGINT NOT NULL DEFAULT 0 COMMENT '该成员已读到的最大消息 id,未读真相;Redis 丢了可从此重建',
+ MODIFY COLUMN joined_at DATETIME(3) NOT NULL COMMENT '加入会话时间',
+ MODIFY COLUMN muted TINYINT NOT NULL DEFAULT 0 COMMENT '0=未静音 1=免打扰';
+
+ALTER TABLE messages COMMENT = '消息正文。PK=MsgId;content 为序列化 BLOB,不存媒体二进制';
+ALTER TABLE messages
+ MODIFY COLUMN id BIGINT NOT NULL COMMENT 'Snowflake MsgId,主键、排序键、同步游标',
+ MODIFY COLUMN conversation_id BIGINT NOT NULL COMMENT '所属会话 id',
+ MODIFY COLUMN sender_id BIGINT NOT NULL COMMENT '发送者 user_id',
+ MODIFY COLUMN client_msg_id VARCHAR(64) NOT NULL COMMENT '客户端消息 id,与 sender+会话 组成幂等键',
+ MODIFY COLUMN message_type SMALLINT NOT NULL COMMENT '消息类型:1文本 2图片 3语音 4视频 5表情 6GIF 7文件 8位置 9系统',
+ MODIFY COLUMN content BLOB NOT NULL COMMENT '序列化后的消息体(JSON 字节),禁止存原始 string;媒体只存对象存储 key',
+ MODIFY COLUMN status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 2=已撤回 3=审核隐藏',
+ MODIFY COLUMN created_at DATETIME(3) NOT NULL COMMENT '服务端入库时间';
+
+ALTER TABLE mailbox_events COMMENT = '失败邮箱与控制事件。成功在线推送的新消息不写此表';
+ALTER TABLE mailbox_events
+ MODIFY COLUMN id BIGINT NOT NULL COMMENT 'Snowflake 事件 id',
+ MODIFY COLUMN user_id BIGINT NOT NULL COMMENT '接收用户',
+ MODIFY COLUMN device_id BIGINT NOT NULL COMMENT '目标设备;0 表示该用户全部设备',
+ MODIFY COLUMN seq BIGINT NOT NULL COMMENT '该 user+device 上单调递增的邮箱序号',
+ MODIFY COLUMN event_type SMALLINT NOT NULL COMMENT '事件类型:撤回/踢人/成员变更/好友/漏推消息等,不含成功在线推送的新消息',
+ MODIFY COLUMN conversation_id BIGINT NULL COMMENT '相关会话,可空',
+ MODIFY COLUMN payload VARBINARY(4096) COMMENT '控制事件载荷(会话 id+消息 id 等),不含消息正文',
+ MODIFY COLUMN created_at DATETIME(3) NOT NULL COMMENT '写入时间';
+
+ALTER TABLE device_sync_cursors COMMENT = '每设备同步游标。mailbox_seq 对邮箱,dialog_score 对会话列表';
+ALTER TABLE device_sync_cursors
+ MODIFY COLUMN user_id BIGINT NOT NULL COMMENT '用户 id',
+ MODIFY COLUMN device_id BIGINT NOT NULL COMMENT '设备 id',
+ MODIFY COLUMN mailbox_seq BIGINT NOT NULL DEFAULT 0 COMMENT '该设备已同步到的 mailbox_events.seq',
+ MODIFY COLUMN dialog_score BIGINT NOT NULL DEFAULT 0 COMMENT '会话列表 Redis ZSET 游标',
+ MODIFY COLUMN updated_at DATETIME(3) NOT NULL COMMENT '游标更新时间';
+
+ALTER TABLE users COMMENT = '用户账号。主键 Snowflake;登录身份在 user_emails,设备在 user_devices';
+ALTER TABLE users
+ MODIFY COLUMN id BIGINT NOT NULL COMMENT 'Snowflake 用户 id(uid),时间有序,非自增',
+ MODIFY COLUMN spark_id VARCHAR(32) NOT NULL COMMENT '对外展示的账号,用户可改,全局唯一',
+ MODIFY COLUMN nickname VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称',
+ MODIFY COLUMN avatar VARCHAR(512) NOT NULL DEFAULT '' COMMENT '头像对象存储 key 或 URL,不存图片二进制',
+ MODIFY COLUMN status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 0=禁用',
+ MODIFY COLUMN created_at DATETIME(3) NOT NULL COMMENT '注册时间',
+ MODIFY COLUMN updated_at DATETIME(3) NOT NULL COMMENT '资料最近更新时间';
+
+ALTER TABLE user_emails COMMENT = '邮箱与用户的一对一映射。登录用邮箱查此表再读 users';
+ALTER TABLE user_emails
+ MODIFY COLUMN email VARCHAR(255) NOT NULL COMMENT '规范化后的小写邮箱,登录账号',
+ MODIFY COLUMN user_id BIGINT NOT NULL COMMENT '绑定的 users.id,一对一',
+ MODIFY COLUMN created_at DATETIME(3) NOT NULL COMMENT '绑定时间';
+
+ALTER TABLE user_devices COMMENT = '登录过的设备。同 user+device 覆盖更新;踢人按 device_id';
+ALTER TABLE user_devices
+ MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增,仅内部用;业务唯一键是 (user_id, device_id)',
+ MODIFY COLUMN user_id BIGINT NOT NULL COMMENT '所属用户',
+ MODIFY COLUMN device_id BIGINT UNSIGNED NOT NULL COMMENT '客户端设备 id,写入 JWT did',
+ MODIFY COLUMN platform VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'ios/android/web 等',
+ MODIFY COLUMN device_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '设备展示名',
+ MODIFY COLUMN app_version VARCHAR(32) NOT NULL DEFAULT '' COMMENT '最近一次登录的客户端版本',
+ MODIFY COLUMN language VARCHAR(16) NOT NULL DEFAULT '' COMMENT '最近一次登录语言,如 zh-CN',
+ MODIFY COLUMN last_ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT '最近一次登录的 PROXY RealIP,禁止用环回地址',
+ MODIFY COLUMN last_login_at DATETIME(3) NOT NULL COMMENT '最近一次登录时间',
+ MODIFY COLUMN created_at DATETIME(3) NOT NULL COMMENT '该设备首次登录时间';
diff --git a/migrations/0004_user_devices_autoincrement.sql b/migrations/0004_user_devices_autoincrement.sql
new file mode 100644
index 0000000..b9cf0f5
--- /dev/null
+++ b/migrations/0004_user_devices_autoincrement.sql
@@ -0,0 +1,5 @@
+-- user_devices.id is a table-local AUTO_INCREMENT row key, not Snowflake.
+-- Business identity remains (user_id, device_id). Existing Snowflake values stay; new rows continue from MAX(id)+1.
+
+ALTER TABLE user_devices
+ MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增,仅内部用;业务唯一键是 (user_id, device_id)';
diff --git a/migrations/0005_user_profile.sql b/migrations/0005_user_profile.sql
new file mode 100644
index 0000000..5750814
--- /dev/null
+++ b/migrations/0005_user_profile.sql
@@ -0,0 +1,9 @@
+-- Profile fields on users. One ADD per statement (stock MySQL has no ADD COLUMN IF NOT EXISTS).
+-- Re-running migrate ignores duplicate-column errors.
+
+ALTER TABLE users ADD COLUMN qrcode VARCHAR(512) NOT NULL DEFAULT '' COMMENT '个人二维码对象存储 key 或 URL,不存图片二进制';
+ALTER TABLE users ADD COLUMN birthday DATE NULL COMMENT '生日,未填为空';
+ALTER TABLE users ADD COLUMN gender TINYINT NOT NULL DEFAULT 0 COMMENT '0=未知 1=男 2=女';
+ALTER TABLE users ADD COLUMN last_login_at DATETIME(3) NULL COMMENT '最近一次登录时间';
+ALTER TABLE users ADD COLUMN last_active_at DATETIME(3) NULL COMMENT '最近一次活跃时间(登录、心跳、发消息等)';
+ALTER TABLE users COMMENT = '用户账号。主键 Snowflake 即 uid;登录身份在 user_emails,设备在 user_devices';
diff --git a/migrations/0006_user_login_logs.sql b/migrations/0006_user_login_logs.sql
new file mode 100644
index 0000000..7348598
--- /dev/null
+++ b/migrations/0006_user_login_logs.sql
@@ -0,0 +1,18 @@
+-- user_login_logs: one row per successful login. user_devices.platform is the device type.
+
+CREATE TABLE IF NOT EXISTS user_login_logs (
+ id BIGINT NOT NULL AUTO_INCREMENT COMMENT '行 id,表内自增',
+ user_id BIGINT NOT NULL COMMENT '登录用户',
+ device_id BIGINT UNSIGNED NOT NULL COMMENT '客户端设备 id',
+ device_type VARCHAR(16) NOT NULL COMMENT '设备类型:ios/android/harmony/windows/macos/web',
+ device_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '设备展示名',
+ app_version VARCHAR(32) NOT NULL DEFAULT '' COMMENT '客户端版本',
+ language VARCHAR(16) NOT NULL DEFAULT '' COMMENT '登录语言',
+ ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'PROXY RealIP',
+ created_at DATETIME(3) NOT NULL COMMENT '本次登录时间',
+ PRIMARY KEY (id),
+ KEY idx_user_created (user_id, created_at)
+) COMMENT='用户登录记录。每次成功登录追加一行;当前设备以 user_devices 为准';
+
+ALTER TABLE user_devices
+ MODIFY COLUMN platform VARCHAR(32) NOT NULL DEFAULT '' COMMENT '设备类型:ios/android/harmony/windows/macos/web';
diff --git a/migrations/0007_friends.sql b/migrations/0007_friends.sql
new file mode 100644
index 0000000..fc7f2e5
--- /dev/null
+++ b/migrations/0007_friends.sql
@@ -0,0 +1,50 @@
+-- Bidirectional friends, one pending request per pair, unidirectional block/remark/report.
+
+CREATE TABLE IF NOT EXISTS friend_relations (
+ user_small BIGINT NOT NULL COMMENT '两人中较小的 user_id,保证同一对用户只有一行',
+ user_big BIGINT NOT NULL COMMENT '两人中较大的 user_id',
+ created_at DATETIME(3) NOT NULL COMMENT '成为好友时间',
+ PRIMARY KEY (user_small, user_big),
+ KEY idx_user_big (user_big)
+) COMMENT='双向好友。无单向好友;删除任一方即解除双方关系';
+
+CREATE TABLE IF NOT EXISTS friend_requests (
+ id BIGINT PRIMARY KEY COMMENT 'Snowflake 申请 id',
+ from_user BIGINT NOT NULL COMMENT '申请人 user_id',
+ to_user BIGINT NOT NULL COMMENT '被申请人 user_id',
+ source TINYINT NOT NULL COMMENT '来源:1=二维码 2=邮箱 3=spark_id',
+ reason VARCHAR(128) NOT NULL DEFAULT '' COMMENT '申请理由',
+ remark VARCHAR(64) NOT NULL DEFAULT '' COMMENT '申请人对对方的备注,同意后写入 friend_remarks',
+ status TINYINT NOT NULL DEFAULT 1 COMMENT '1=待处理 2=已同意 3=已拒绝',
+ created_at DATETIME(3) NOT NULL COMMENT '首次申请时间',
+ updated_at DATETIME(3) NOT NULL COMMENT '最近一次申请或处理时间',
+ UNIQUE KEY uk_from_to (from_user, to_user),
+ KEY idx_to_status (to_user, status, updated_at)
+) COMMENT='好友申请。同一对 from→to 只保留一行,重复发起覆盖理由与备注';
+
+CREATE TABLE IF NOT EXISTS friend_remarks (
+ user_id BIGINT NOT NULL COMMENT '备注所属用户',
+ friend_id BIGINT NOT NULL COMMENT '被备注的用户',
+ remark VARCHAR(64) NOT NULL DEFAULT '' COMMENT '备注名,仅对备注者可见',
+ updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
+ PRIMARY KEY (user_id, friend_id)
+) COMMENT='好友备注。单向,解除好友时删除双方备注';
+
+CREATE TABLE IF NOT EXISTS user_blocks (
+ user_id BIGINT NOT NULL COMMENT '拉黑人 user_id',
+ blocked_id BIGINT NOT NULL COMMENT '被拉黑人 user_id',
+ created_at DATETIME(3) NOT NULL COMMENT '拉黑时间',
+ PRIMARY KEY (user_id, blocked_id),
+ KEY idx_blocked (blocked_id)
+) COMMENT='拉黑。单向;拉黑时解除好友关系';
+
+CREATE TABLE IF NOT EXISTS friend_reports (
+ id BIGINT PRIMARY KEY COMMENT 'Snowflake 投诉 id',
+ user_id BIGINT NOT NULL COMMENT '投诉人 user_id',
+ target_id BIGINT NOT NULL COMMENT '被投诉人 user_id',
+ reason TINYINT NOT NULL COMMENT '投诉类型:1=垃圾 2=骚扰 3=不实身份 4=其他',
+ detail VARCHAR(512) NOT NULL DEFAULT '' COMMENT '投诉说明',
+ created_at DATETIME(3) NOT NULL COMMENT '投诉时间',
+ KEY idx_user_created (user_id, created_at),
+ KEY idx_target (target_id)
+) COMMENT='好友投诉。仅记录,不自动拉黑';
diff --git a/migrations/0008_favorites.sql b/migrations/0008_favorites.sql
new file mode 100644
index 0000000..95e823f
--- /dev/null
+++ b/migrations/0008_favorites.sql
@@ -0,0 +1,13 @@
+-- 用户收藏。媒体对象复制到 perm/fav,不引用 chat/ 原 key;源对象过期则无法再收藏。
+
+CREATE TABLE IF NOT EXISTS favorites (
+ id BIGINT PRIMARY KEY COMMENT 'Snowflake 收藏 id',
+ user_id BIGINT NOT NULL COMMENT '收藏者 user_id',
+ msg_id BIGINT NOT NULL COMMENT '原消息 Snowflake id,仅作来源;撤回不影响收藏',
+ conversation_id BIGINT NOT NULL COMMENT '原会话 id,仅作来源',
+ message_type SMALLINT NOT NULL COMMENT '消息类型,与 messages.message_type 一致',
+ content BLOB NOT NULL COMMENT '收藏快照(JSON 字节);媒体 key 已复制到 perm/fav/',
+ created_at DATETIME(3) NOT NULL COMMENT '收藏时间',
+ UNIQUE KEY uk_user_msg (user_id, msg_id),
+ KEY idx_user_id (user_id, id)
+) COMMENT='用户收藏。媒体复制到 perm/fav;chat/ 源过期后收藏失败,不提供补传';
diff --git a/migrations/0009_groups.sql b/migrations/0009_groups.sql
new file mode 100644
index 0000000..0fd3266
--- /dev/null
+++ b/migrations/0009_groups.sql
@@ -0,0 +1,29 @@
+-- Group id is a table-local AUTO_INCREMENT 群号. Chat / sync still use Snowflake conversations.id.
+
+CREATE TABLE IF NOT EXISTS `groups` (
+ id BIGINT NOT NULL AUTO_INCREMENT COMMENT '群号,表内自增,对外群 ID',
+ conversation_id BIGINT NOT NULL COMMENT '对应 conversations.id(Snowflake),发消息与同步用',
+ name VARCHAR(32) NOT NULL COMMENT '群名称',
+ avatar VARCHAR(512) NOT NULL DEFAULT '' COMMENT '群头像对象存储 key,空表示未设置',
+ owner_id BIGINT NOT NULL COMMENT '群主 user_id',
+ muted_all TINYINT NOT NULL DEFAULT 0 COMMENT '0=未全员禁言 1=全员禁言(群主/管理员仍可发言)',
+ member_count INT NOT NULL DEFAULT 0 COMMENT '当前成员数,含群主',
+ status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 2=已解散',
+ created_at DATETIME(3) NOT NULL COMMENT '创建时间',
+ updated_at DATETIME(3) NOT NULL COMMENT '资料或成员变更时间',
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_conversation (conversation_id),
+ KEY idx_owner_id (owner_id),
+ KEY idx_status_updated (status, updated_at)
+) COMMENT='群资料。id 是自增群号;会话列表与未读仍按 conversation_id 走 Redis';
+
+CREATE TABLE IF NOT EXISTS group_members (
+ group_id BIGINT NOT NULL COMMENT '群号,groups.id',
+ user_id BIGINT NOT NULL COMMENT '成员 user_id',
+ role TINYINT NOT NULL DEFAULT 3 COMMENT '1=群主 2=管理员 3=普通成员',
+ nickname VARCHAR(32) NOT NULL DEFAULT '' COMMENT '群内昵称,空则用用户昵称',
+ speak_muted TINYINT NOT NULL DEFAULT 0 COMMENT '0=未禁言 1=被单独禁言',
+ joined_at DATETIME(3) NOT NULL COMMENT '入群时间',
+ PRIMARY KEY (group_id, user_id),
+ KEY idx_user_id (user_id)
+) COMMENT='群成员与角色。热路径 MemberIDs 可短缓存;免打扰在 conversation_members';