feat: implement Spark IM V3 modular monolith
Add HTTP /v1 MVC, TCP send path, groups with server-side member-avatar collage, object-storage upload, and friends/favorites around the architecture-v3 layout. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6ce89511fa
commit
ae9a71034d
16
.cursor/rules/http-mvc.mdc
Normal file
16
.cursor/rules/http-mvc.mdc
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
description: HTTP MVC, validator i18n keys, RealIP and language on context, REST groups.
|
||||
globs: internal/transport/http/**/*.go,internal/pkg/validate/**/*.go,internal/pkg/reqctx/**/*.go,internal/pkg/i18n/**/*.go
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# HTTP MVC
|
||||
|
||||
Source: `.cursor/skills/spark-im-v3/http.md` and `i18n.md`.
|
||||
|
||||
- Handlers only bind and call Application services. Login and other business live in `internal/module/*`, never in Gin handlers.
|
||||
- Bind JSON with `respond.BindJSON` + `validate` / `label` / `errmsg` **i18n keys**. Client `msg` is localized (`zh-CN` / `en` / `zh-TW`).
|
||||
- Put `RealIP` and `Language` on `reqctx.RequestContext`. HTTP: `X-Language` or `Accept-Language`. TCP: AUTH `language`. Rate limit uses `reqctx.RealIP(ctx)`. Errors/notices use `reqctx.Lang(ctx)`.
|
||||
- Routes are REST under `/v1/{resource}` and grouped by business in `router.go`.
|
||||
- Do not import package `httpx` from `handler/*` (cycle). Use `internal/transport/http/respond`.
|
||||
- Do not hardcode error or notification strings; add catalog entries in `internal/pkg/i18n`.
|
||||
@ -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.
|
||||
|
||||
19
.cursor/rules/module-layers.mdc
Normal file
19
.cursor/rules/module-layers.mdc
Normal file
@ -0,0 +1,19 @@
|
||||
---
|
||||
description: Module layering — entity, model, logic. One GORM file per table.
|
||||
globs: internal/module/**/*.go
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Module layers
|
||||
|
||||
Each business module under `internal/module/{biz}` is three packages:
|
||||
|
||||
| Layer | Path | Owns |
|
||||
|-------|------|------|
|
||||
| entity | `{biz}/entity` | Domain structs, message payload types, no I/O |
|
||||
| model | `{biz}/model` | One Go file per MySQL table (GORM + COMMENT tags), Redis adapters |
|
||||
| logic | `{biz}/logic` | One file per **function** (profile, login, send, inbox sync). Not one file per table. |
|
||||
|
||||
`logic` must not import `model`. Wire implementations in `internal/bootstrap`. Auth has no tables (entity + logic only).
|
||||
|
||||
Message `content` is serialized JSON **bytes** in a BLOB. Never persist a raw string. Media is an object-storage key, never file bytes.
|
||||
@ -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`
|
||||
|
||||
105
.cursor/skills/spark-im-v3/http.md
Normal file
105
.cursor/skills/spark-im-v3/http.md
Normal file
@ -0,0 +1,105 @@
|
||||
# HTTP: validator, RealIP, REST groups, MVC
|
||||
|
||||
HTTP and TCP share Application services in `internal/module/*`. Gin is a thin adapter.
|
||||
|
||||
## MVC
|
||||
|
||||
| Layer | Lives in | Owns |
|
||||
|-------|----------|------|
|
||||
| Model / application | `internal/module/{biz}/{entity,model,logic}` | Domain types; one GORM file per table; logic by function, not by table |
|
||||
| Controller | `internal/transport/http/handler/{biz}` | Bind, call service, write envelope |
|
||||
| View | `internal/pkg/reply` + `internal/transport/http/respond` | `{code,msg,data}` |
|
||||
|
||||
Handlers must not contain business rules (credential check, token issue, friend/group permission, persist). Example: login belongs on `auth/logic.LoginService`, not in the Gin handler.
|
||||
|
||||
```go
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
var in auth.LoginInput
|
||||
if err := respond.BindJSON(c, &in); err != nil {
|
||||
respond.JSONError(c, err)
|
||||
return
|
||||
}
|
||||
tok, err := h.login.Login(c.Request.Context(), &in)
|
||||
if err != nil {
|
||||
respond.JSONError(c, err)
|
||||
return
|
||||
}
|
||||
respond.JSON(c, tok)
|
||||
}
|
||||
```
|
||||
|
||||
Wire services in `internal/bootstrap`, pass them as `httpx.EngineDeps`. TCP commands call the same services.
|
||||
|
||||
Do not import `internal/transport/http` (package `httpx`) from `handler/*` — that is an import cycle. Use `respond` for Bind/JSON.
|
||||
|
||||
## Validator (`github.com/go-playground/validator/v10`)
|
||||
|
||||
Use `validate` tags. Bind with `respond.BindJSON` (JSON decode + `validate.StructLang`). Do not use Gin's `binding` tags as the source of client messages.
|
||||
|
||||
`label` and `errmsg` are **i18n keys** (see [i18n.md](i18n.md)), not a single language's sentences:
|
||||
|
||||
- `label:"field.email"` — field name in default templates (`validate.required` → `{label}不能为空` / `{label} is required`)
|
||||
- `errmsg:"required:validate.login.email_required"` — per-tag key, `;` separates tags
|
||||
|
||||
Failed validation is `errs.ErrInvalidArgument` (`40001`) with `Msg` already localized. Transport must not replace it.
|
||||
|
||||
Shared DTO example (`internal/module/auth/logic.LoginInput`):
|
||||
|
||||
```go
|
||||
type LoginInput struct {
|
||||
Email string `json:"email" validate:"required,email" label:"field.email" errmsg:"required:validate.login.email_required;email:validate.login.email_invalid"`
|
||||
VerifyCode string `json:"verify_code" validate:"required" label:"field.verify_code" errmsg:"required:validate.login.code_required"`
|
||||
DeviceID uint64 `json:"device_id" validate:"required" label:"field.device_id" errmsg:"required:validate.login.device_required"`
|
||||
}
|
||||
```
|
||||
|
||||
TCP may call `validate.StructLang` / `LoginService` on the same struct. Keep DTOs in the module, not in the handler package.
|
||||
|
||||
## RealIP on context (C6)
|
||||
|
||||
`reqctx.RequestContext.RealIP` is the client address for rate limit and audit. Never use gnet `RemoteAddr` (Nginx → 127.0.0.1).
|
||||
|
||||
| Transport | How RealIP is set |
|
||||
|-----------|-------------------|
|
||||
| HTTP | `X-Real-IP`, else first `X-Forwarded-For`, else `RemoteAddr` host (`reqctx.ParseHTTPRealIP`) |
|
||||
| TCP | PROXY protocol on the session, copied in `session.RequestContext()` |
|
||||
|
||||
Read it with `reqctx.RealIP(ctx)`. Trace middleware must attach `*reqctx.RequestContext` before Auth and handlers. Zap fields already include `real_ip` via `logger.Attach`.
|
||||
|
||||
Gin binds loopback only (C4); Nginx on 443 sets `X-Real-IP`. `SetTrustedProxies` is `127.0.0.1` / `::1`.
|
||||
|
||||
## Language on context
|
||||
|
||||
Every HTTP request carries `X-Language` or `Accept-Language`. TCP AUTH carries `language`. Canonical values: `zh-CN` (default), `en`, `zh-TW`. Details: [i18n.md](i18n.md).
|
||||
|
||||
## REST grouping
|
||||
|
||||
Public HTTP is `/v1/{resource}/...`. Register with Gin groups in `internal/transport/http/router.go`, one function per business.
|
||||
|
||||
| Group | Prefix | Examples |
|
||||
|-------|--------|----------|
|
||||
| system | `/healthz`, `/readyz`, `/docs` | liveness, readiness, OpenAPI |
|
||||
| auth | `/v1/auth` | `POST /v1/auth/login`, `POST /v1/auth/email-codes`, `POST /v1/auth/token/refresh`, `POST /v1/auth/logout` |
|
||||
| users | `/v1/users` | `GET/PATCH /v1/users/me` |
|
||||
| friends | `/v1/friends` | apply, requests, accept, profile, remark, block, report |
|
||||
| groups | `/v1/groups` | create, profile, members, invite, kick, leave, transfer, dissolve |
|
||||
| conversations | `/v1/conversations` | list, mute |
|
||||
| messages | `/v1/messages` | history (sync still prefers TCP) |
|
||||
| files | `/v1/files` | upload credential |
|
||||
|
||||
Rules:
|
||||
|
||||
- Noun resources, HTTP verbs for actions. No `/getUser`, `/sendMessage` under HTTP.
|
||||
- Login stays `POST /v1/auth/login` (create session). Do not put login under `/v1/users`.
|
||||
- Anonymous paths belong in `publicPath` (login, email-codes, token refresh, healthz, readyz, docs).
|
||||
- OpenAPI `tags` must match these groups.
|
||||
|
||||
## Checklist when adding an HTTP API
|
||||
|
||||
1. Input struct in `internal/module/{biz}` with `validate` / `label` / `errmsg` **keys**.
|
||||
2. Use-case method on a service in that module (HTTP and TCP share it).
|
||||
3. Thin handler under `internal/transport/http/handler/{biz}`.
|
||||
4. Register on the matching `v1.Group` in `router.go`.
|
||||
5. Document the path and tag in `internal/transport/http/docs/openapi.yaml`.
|
||||
6. Assert `reqctx.RealIP(ctx)` wherever you rate-limit or audit.
|
||||
7. Localize errors/notifications via `internal/pkg/i18n`; never hardcode `msg`.
|
||||
57
.cursor/skills/spark-im-v3/i18n.md
Normal file
57
.cursor/skills/spark-im-v3/i18n.md
Normal file
@ -0,0 +1,57 @@
|
||||
# i18n (zh-CN / en / zh-TW)
|
||||
|
||||
User-visible **error `msg`** and **notification copy** are never hardcoded in handlers. They come from `internal/pkg/i18n` using the request language.
|
||||
|
||||
## Language on every request
|
||||
|
||||
Canonical tags: `zh-CN` (default), `en`, `zh-TW`. Parse with `i18n.Parse`; store on `reqctx.RequestContext.Language`. Read with `reqctx.Lang(ctx)` / `reqctx.Language(ctx)`.
|
||||
|
||||
| Transport | Client sends | Server stores |
|
||||
|-----------|--------------|----------------|
|
||||
| HTTP | `X-Language` if set, else `Accept-Language` | Trace middleware, every request |
|
||||
| TCP | AUTH JSON field `language` | `session.Conn.SetLanguage` for the connection; copied into each command's `RequestContext` |
|
||||
|
||||
Missing or unknown tags → `zh-CN`. `zh` / `zh-Hans` → `zh-CN`. `zh-TW` / `zh-HK` / `zh-Hant` → `zh-TW`. `en*` → `en`.
|
||||
|
||||
Do not put language in JWT. Rate limit still uses `reqctx.RealIP`, not language.
|
||||
|
||||
## Errors
|
||||
|
||||
Sentinel `AppError` keeps an English `Msg` for logs. Envelope `msg` is localized at the transport boundary:
|
||||
|
||||
- HTTP: `reply.FromContext(ctx, err)`
|
||||
- TCP: `protocol.EncodeErrLang(i18n.Parse(conn.Language()), err)`
|
||||
|
||||
`WithMsg` is only for **already-localized** text (validator). Do not `WithMsg("unauthorized")` in a handler.
|
||||
|
||||
Add a new business code: register `err.{code}` in `internal/pkg/i18n/catalog_err.go` for all three languages.
|
||||
|
||||
## Validation
|
||||
|
||||
`label` and `errmsg` are **catalog keys**, not Chinese sentences:
|
||||
|
||||
```go
|
||||
Email string `json:"email" validate:"required,email" label:"field.email" errmsg:"required:validate.login.email_required"`
|
||||
```
|
||||
|
||||
`validate.StructLang(reqctx.Lang(ctx), in)`. Default templates are `validate.required` etc. with `{label}` / `{param}`.
|
||||
|
||||
## Notification types
|
||||
|
||||
Push and in-app system notices use type keys, then `i18n.Notify(lang, typ, args)` for title/body. Recipient language is the **target device** session language (TCP) or the user's stored lang — never the sender's.
|
||||
|
||||
| Type | Key |
|
||||
|------|-----|
|
||||
| Same-device kick | `notify.kick.replaced` |
|
||||
| Friend request | `notify.friend.request` |
|
||||
| Group invite | `notify.group.invite` |
|
||||
| Removed from group | `notify.group.removed` |
|
||||
| Message recall | `notify.message.recall` |
|
||||
|
||||
`push.Notification.Type` holds the key. Fill `Title`/`Body` with `i18n.Notify` before `Notifier.Notify`. Add new types in `catalog_notify.go` (`.title` / `.body`, `{name}` placeholders).
|
||||
|
||||
## Do not
|
||||
|
||||
- Hardcode `msg` or kick/push strings in `handler/*` or EventLoop.
|
||||
- Localize with the sender language when notifying another user.
|
||||
- Introduce a fourth locale without adding it to `i18n.Lang` and every catalog entry.
|
||||
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@ -0,0 +1,16 @@
|
||||
.git
|
||||
.cursor
|
||||
.idea
|
||||
.vscode
|
||||
bin
|
||||
logs
|
||||
vendor
|
||||
examples
|
||||
docs
|
||||
*.md
|
||||
*.test
|
||||
*.out
|
||||
coverage.out
|
||||
.env
|
||||
certs
|
||||
deploy/nginx
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
bin/
|
||||
logs/
|
||||
vendor/
|
||||
*.exe
|
||||
*.test
|
||||
|
||||
@ -5,7 +5,7 @@ run:
|
||||
tests: true
|
||||
go: "1.26"
|
||||
|
||||
linters-settings:
|
||||
linters_settings:
|
||||
funlen:
|
||||
lines: 100
|
||||
statements: 100
|
||||
|
||||
@ -7,6 +7,7 @@ Before writing code:
|
||||
1. Load `.cursor/skills/spark-im-v3/SKILL.md`
|
||||
2. Follow `.cursor/rules/` (always-on V3 invariants + file-scoped rules)
|
||||
3. Keep functions within `.golangci.yaml` (`funlen` 100, `lll` 240)
|
||||
4. HTTP and TCP share Application services under `internal/module/*`
|
||||
4. HTTP and TCP share Application services under `internal/module/{biz}/logic`. Each module is `entity` / `model` (one file per table) / `logic` (one file per function, not per table). Gin handlers only bind and call those services (MVC). REST paths are `/v1/{resource}` grouped by business. Request DTOs use go-playground/validator (`label` / `errmsg` i18n keys). Context must carry `reqctx.RealIP` and the user language (`zh-CN` / `en` / `zh-TW`). Error `msg` and notification copy come from `internal/pkg/i18n`. Table and column COMMENT is required. Message `content` is serialized bytes, not a string.
|
||||
5. After adding or changing `migrations/*.sql`, run `go run ./cmd/migrate -config configs/config.yaml` yourself. Do not ask the user to apply it later.
|
||||
|
||||
Do not introduce Kafka, K8s, microservices, ConversationSeq, application-layer handshake crypto, or public gnet ports.
|
||||
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@ -0,0 +1,25 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM golang:1.26-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/spark-server ./cmd/server \
|
||||
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/spark-migrate ./cmd/migrate
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates tzdata wget \
|
||||
&& adduser -D -H -u 10001 spark
|
||||
ENV TZ=Asia/Shanghai
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/spark-server /usr/local/bin/spark-server
|
||||
COPY --from=build /out/spark-migrate /usr/local/bin/spark-migrate
|
||||
COPY migrations /app/migrations
|
||||
COPY deploy/docker/config.docker.yaml /app/configs/config.yaml
|
||||
RUN mkdir -p /app/logs && chown -R spark:spark /app
|
||||
USER spark
|
||||
EXPOSE 8080 9000
|
||||
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1
|
||||
ENTRYPOINT ["/usr/local/bin/spark-server"]
|
||||
CMD ["-config", "/app/configs/config.yaml"]
|
||||
14
Makefile
14
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
|
||||
|
||||
13
README.md
13
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`.
|
||||
|
||||
129
cmd/migrate/main.go
Normal file
129
cmd/migrate/main.go
Normal file
@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"spark-server/internal/bootstrap"
|
||||
"spark-server/internal/infrastructure/mysql"
|
||||
redisx "spark-server/internal/infrastructure/redis"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfgPath := flag.String("config", "configs/config.yaml", "config path")
|
||||
dir := flag.String("dir", "migrations", "SQL migrations directory")
|
||||
flag.Parse()
|
||||
if err := run(*cfgPath, *dir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(cfgPath, dir string) error {
|
||||
cfg, err := bootstrap.Load(cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log := zap.NewNop()
|
||||
db, err := mysql.Open(log, &mysql.Options{
|
||||
DSN: cfg.MySQL.DSN,
|
||||
SlowThreshold: cfg.MySQL.SlowThreshold,
|
||||
MaxOpenConns: cfg.MySQL.MaxOpenConns,
|
||||
MaxIdleConns: cfg.MySQL.MaxIdleConns,
|
||||
ConnMaxLifetime: cfg.MySQL.ConnMaxLifetime,
|
||||
ConnMaxIdleTime: cfg.MySQL.ConnMaxIdleTime,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("mysql: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if !db.Ready() {
|
||||
return fmt.Errorf("mysql: dsn is empty")
|
||||
}
|
||||
rdb, err := redisx.Open(&redisx.Options{
|
||||
Addr: cfg.Redis.Addr,
|
||||
Username: cfg.Redis.Username,
|
||||
Password: cfg.Redis.Password,
|
||||
DB: cfg.Redis.DB,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("redis: %w", err)
|
||||
}
|
||||
defer rdb.Close()
|
||||
if !rdb.Ready() {
|
||||
return fmt.Errorf("redis: addr is empty")
|
||||
}
|
||||
pingCtx, pingCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer pingCancel()
|
||||
if err := rdb.Ping(pingCtx); err != nil {
|
||||
return fmt.Errorf("redis ping: %w", err)
|
||||
}
|
||||
files, err := filepath.Glob(filepath.Join(dir, "*.sql"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gdb := db.Gorm()
|
||||
ctx := context.Background()
|
||||
for _, path := range files {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, stmt := range splitSQL(string(raw)) {
|
||||
if err := gdb.WithContext(ctx).Exec(stmt).Error; err != nil {
|
||||
if ignorableDDL(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("%s statement %d: %w", filepath.Base(path), i+1, err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("ok %s\n", filepath.Base(path))
|
||||
}
|
||||
fmt.Println("mysql ok, redis ok")
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitSQL(raw string) []string {
|
||||
var b strings.Builder
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
trim := strings.TrimSpace(line)
|
||||
if trim == "" || strings.HasPrefix(trim, "--") {
|
||||
continue
|
||||
}
|
||||
b.WriteString(line)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
parts := strings.Split(b.String(), ";")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
s := strings.TrimSpace(p)
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ignorableDDL(err error) bool {
|
||||
var me *mysqldriver.MySQLError
|
||||
if !errors.As(err, &me) {
|
||||
return false
|
||||
}
|
||||
switch me.Number {
|
||||
case 1050, // table already exists
|
||||
1060, // duplicate column
|
||||
1061: // duplicate key name
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
93
deploy/docker/config.docker.yaml
Normal file
93
deploy/docker/config.docker.yaml
Normal file
@ -0,0 +1,93 @@
|
||||
env: prod
|
||||
|
||||
server:
|
||||
http:
|
||||
addr: "0.0.0.0:8080"
|
||||
tcp:
|
||||
addr: "0.0.0.0:9000"
|
||||
proxy_protocol: true
|
||||
tls:
|
||||
cert_file: ""
|
||||
key_file: ""
|
||||
max_frame_size: 65536
|
||||
idle_timeout: 120s
|
||||
keepalive: 30s
|
||||
recv_buffer: 16777216
|
||||
send_buffer: 16777216
|
||||
multicore: true
|
||||
compress_min_size: 512
|
||||
|
||||
id:
|
||||
snowflake_worker_id: 1
|
||||
|
||||
log:
|
||||
level: info
|
||||
encoding: json
|
||||
console: true
|
||||
file:
|
||||
path: "logs/spark-server.log"
|
||||
max_size_mb: 100
|
||||
max_backups: 7
|
||||
max_age_days: 7
|
||||
compress: true
|
||||
|
||||
mysql:
|
||||
dsn: "spark:@tcp(mysql:3306)/spark?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
slow_threshold: 200ms
|
||||
log_level: ""
|
||||
max_open_conns: 64
|
||||
max_idle_conns: 16
|
||||
conn_max_lifetime: 30m
|
||||
conn_max_idle_time: 10m
|
||||
|
||||
redis:
|
||||
addr: "redis:6379"
|
||||
username: ""
|
||||
password: ""
|
||||
db: 0
|
||||
deliver_queue_key: "deliver:queue"
|
||||
deliver_processing_key: "deliver:processing"
|
||||
deliver_done_prefix: "deliver:done:"
|
||||
|
||||
jwt:
|
||||
secret: "dev-only-change-me"
|
||||
issuer: "spark-im"
|
||||
ttl: 168h
|
||||
refresh_ttl: 720h
|
||||
denylist_prefix: "denylist:"
|
||||
refresh_prefix: "refresh:"
|
||||
|
||||
taskpool:
|
||||
size: 256
|
||||
queue: 1024
|
||||
|
||||
deliver:
|
||||
workers: 8
|
||||
take_timeout: 5s
|
||||
done_ttl: 10m
|
||||
|
||||
session:
|
||||
outbound_queue_size: 1024
|
||||
|
||||
rate_limit:
|
||||
message_per_second: 20
|
||||
auth_per_second: 5
|
||||
sync_inbox_per_minute: 60
|
||||
sync_messages_per_minute: 120
|
||||
history_per_minute: 200
|
||||
upload_per_second: 10
|
||||
|
||||
message:
|
||||
max_content_size: 65536
|
||||
sync_limit: 200
|
||||
msgid_expire_ms: 300000
|
||||
|
||||
object_storage:
|
||||
endpoint: "rustfs:9000"
|
||||
access_key: ""
|
||||
secret_key: ""
|
||||
bucket: "spark"
|
||||
region: "us-east-1"
|
||||
use_ssl: false
|
||||
presign_ttl: 15m
|
||||
chat_expire_days: 365
|
||||
28
deploy/docker/deploy.sh
Executable file
28
deploy/docker/deploy.sh
Executable file
@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy spark-server to the ECS host and join mysql/redis/rustfs on shell_default.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
HOST="${SPARK_SSH_HOST:-root@47.236.66.9}"
|
||||
KEY="${SPARK_SSH_KEY:-$HOME/.ssh/spark-ecs}"
|
||||
REMOTE="${SPARK_REMOTE_DIR:-/opt/spark-server}"
|
||||
SSH=(ssh -i "$KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$HOST")
|
||||
|
||||
rsync -az --delete \
|
||||
--exclude '.git/' \
|
||||
--exclude 'bin/' \
|
||||
--exclude 'logs/' \
|
||||
--exclude 'vendor/' \
|
||||
--exclude 'examples/' \
|
||||
--exclude '.cursor/' \
|
||||
--exclude '.idea/' \
|
||||
--exclude '.vscode/' \
|
||||
-e "ssh -i $KEY -o BatchMode=yes" \
|
||||
"$ROOT/" "$HOST:$REMOTE/"
|
||||
|
||||
"${SSH[@]}" "mkdir -p /data/spark-server/logs && chown -R 10001:10001 /data/spark-server/logs"
|
||||
"${SSH[@]}" "cd $REMOTE/deploy/docker && docker compose -f docker-compose.yaml build"
|
||||
"${SSH[@]}" "cd $REMOTE/deploy/docker && docker compose -f docker-compose.yaml up -d spark-server"
|
||||
"${SSH[@]}" "docker exec spark-server /usr/local/bin/spark-migrate -config /app/configs/config.yaml -dir /app/migrations"
|
||||
"${SSH[@]}" "docker ps --filter name=spark-server --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
|
||||
"${SSH[@]}" "curl -fsS http://127.0.0.1:18080/healthz && echo && curl -fsS http://127.0.0.1:18080/readyz && echo"
|
||||
24
deploy/docker/docker-compose.yaml
Normal file
24
deploy/docker/docker-compose.yaml
Normal file
@ -0,0 +1,24 @@
|
||||
# Joins the existing mysql/redis/rustfs network (docker compose project "shell").
|
||||
services:
|
||||
spark-server:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: Dockerfile
|
||||
image: spark-server:latest
|
||||
container_name: spark-server
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
ports:
|
||||
- "127.0.0.1:18080:8080"
|
||||
- "127.0.0.1:19000:9000"
|
||||
volumes:
|
||||
- /data/spark-server/logs:/app/logs
|
||||
- ./config.docker.yaml:/app/configs/config.yaml:ro
|
||||
networks:
|
||||
- shell
|
||||
|
||||
networks:
|
||||
shell:
|
||||
name: shell_default
|
||||
external: true
|
||||
30
deploy/nginx/README.md
Normal file
30
deploy/nginx/README.md
Normal file
@ -0,0 +1,30 @@
|
||||
# Nginx 443 SNI (C3 / C4)
|
||||
|
||||
gnet v2 has no native TLS. IM certificates live here, not in spark-server `tcp.tls`.
|
||||
|
||||
```text
|
||||
Client --TLS--> :443 ssl_preread
|
||||
├─ SNI sparkapi.wyrs365.com → 127.0.0.1:8443 (HTTP TLS)
|
||||
│ → proxy_pass http://127.0.0.1:18080 + X-Real-IP
|
||||
└─ SNI sparkim.wyrs365.com → 127.0.0.1:19001 (IM TLS)
|
||||
→ PROXY protocol → 127.0.0.1:19000 gnet
|
||||
```
|
||||
|
||||
On this ECS, `:443` is shared with other wyrs HTTPS sites. Do **not** replace `/etc/nginx/nginx.conf`. Drop-ins:
|
||||
|
||||
- `host/sparkapi.conf` → `/etc/nginx/conf.d/`
|
||||
- `host/spark-sni.conf` → `/etc/nginx/stream.d/`
|
||||
|
||||
Existing `listen 443 ssl` vhosts move to `127.0.0.1:8443 ssl proxy_protocol` so SNI can take public 443 and `$remote_addr` stays the client IP.
|
||||
|
||||
spark-server (Docker):
|
||||
|
||||
- HTTP `127.0.0.1:18080` → container `:8080`
|
||||
- TCP `127.0.0.1:19000` with `proxy_protocol: true` → container `:9000`
|
||||
- Public security group: 443 only
|
||||
|
||||
```bash
|
||||
bash deploy/nginx/install-host.sh
|
||||
```
|
||||
|
||||
`http.conf` / `stream.conf` / `nginx.conf` are a dedicated-box template. `/readyz` is loopback-only on 8443; local probes hit Gin on `127.0.0.1:18080`.
|
||||
15
deploy/nginx/host/spark-acme.conf
Normal file
15
deploy/nginx/host/spark-acme.conf
Normal file
@ -0,0 +1,15 @@
|
||||
# Temporary HTTP-01 vhosts. Replaced by sparkapi.conf after certificates exist.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name sparkapi.wyrs365.com sparkim.wyrs365.com;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
29
deploy/nginx/host/spark-sni.conf
Normal file
29
deploy/nginx/host/spark-sni.conf
Normal file
@ -0,0 +1,29 @@
|
||||
# Spark IM + shared HTTPS on :443 (ssl_preread).
|
||||
# sparkim.wyrs365.com → IM TLS terminator → PROXY plaintext to gnet :19000
|
||||
# default SNI → existing HTTP sites on 127.0.0.1:8443 (proxy_protocol restores RealIP)
|
||||
|
||||
map $ssl_preread_server_name $spark_sni_backend {
|
||||
sparkim.wyrs365.com 127.0.0.1:19001;
|
||||
default 127.0.0.1:8443;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 reuseport;
|
||||
listen [::]:443 reuseport;
|
||||
ssl_preread on;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_timeout 3600s;
|
||||
proxy_protocol on;
|
||||
proxy_pass $spark_sni_backend;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 127.0.0.1:19001 ssl proxy_protocol;
|
||||
ssl_certificate /etc/letsencrypt/live/sparkim.wyrs365.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/sparkim.wyrs365.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_timeout 3600s;
|
||||
proxy_protocol on;
|
||||
proxy_pass 127.0.0.1:19000;
|
||||
}
|
||||
77
deploy/nginx/host/sparkapi.conf
Normal file
77
deploy/nginx/host/sparkapi.conf
Normal file
@ -0,0 +1,77 @@
|
||||
# Spark HTTP — sparkapi.wyrs365.com
|
||||
# Public :443 is stream ssl_preread (see stream.d/spark-sni.conf).
|
||||
# This vhost terminates TLS on loopback :8443 (PROXY protocol from stream).
|
||||
|
||||
upstream spark_http {
|
||||
server 127.0.0.1:18080;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name sparkapi.wyrs365.com;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name sparkim.wyrs365.com;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 127.0.0.1:8443 ssl proxy_protocol;
|
||||
listen [::1]:8443 ssl proxy_protocol;
|
||||
http2 on;
|
||||
server_name sparkapi.wyrs365.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/sparkapi.wyrs365.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/sparkapi.wyrs365.com/privkey.pem;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SparkSSL:10m;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
|
||||
client_max_body_size 32m;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
location /readyz {
|
||||
allow 127.0.0.1;
|
||||
allow ::1;
|
||||
deny all;
|
||||
proxy_pass http://spark_http;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://spark_http;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,20 @@ http {
|
||||
server {
|
||||
listen 127.0.0.1:8443 ssl;
|
||||
server_name api.example.com;
|
||||
ssl_certificate /etc/nginx/certs/api.example.com.crt;
|
||||
ssl_certificate_key /etc/nginx/certs/api.example.com.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
location /readyz {
|
||||
allow 127.0.0.1;
|
||||
allow ::1;
|
||||
deny all;
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
|
||||
76
deploy/nginx/install-host.sh
Executable file
76
deploy/nginx/install-host.sh
Executable file
@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install Spark SNI vhosts into the shared host nginx (do not replace nginx.conf).
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
HOST_DIR="$ROOT/host"
|
||||
BACKUP="/root/nginx-backup-$(date +%Y%m%d%H%M%S)"
|
||||
|
||||
need_certs() {
|
||||
[[ -f /etc/letsencrypt/live/sparkapi.wyrs365.com/fullchain.pem ]] \
|
||||
&& [[ -f /etc/letsencrypt/live/sparkim.wyrs365.com/fullchain.pem ]]
|
||||
}
|
||||
|
||||
install_acme() {
|
||||
mkdir -p /var/www/certbot
|
||||
cp "$HOST_DIR/spark-acme.conf" /etc/nginx/conf.d/spark-acme.conf
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
}
|
||||
|
||||
issue_certs() {
|
||||
certbot certonly --webroot -w /var/www/certbot \
|
||||
--non-interactive --agree-tos --keep-until-expiring \
|
||||
-d sparkapi.wyrs365.com
|
||||
certbot certonly --webroot -w /var/www/certbot \
|
||||
--non-interactive --agree-tos --keep-until-expiring \
|
||||
-d sparkim.wyrs365.com
|
||||
}
|
||||
|
||||
rewrite_http_443() {
|
||||
local f
|
||||
for f in /etc/nginx/conf.d/*.conf; do
|
||||
case "$(basename "$f")" in
|
||||
sparkapi.conf|spark-acme.conf) continue ;;
|
||||
esac
|
||||
if grep -qE 'listen[[:space:]]+127\.0\.0\.1:8443' "$f"; then
|
||||
continue
|
||||
fi
|
||||
if grep -qE 'listen[[:space:]]+443[[:space:]]+ssl' "$f"; then
|
||||
sed -i \
|
||||
-e 's/listen 443 ssl;/listen 127.0.0.1:8443 ssl proxy_protocol;/' \
|
||||
-e 's/listen \[::\]:443 ssl;/listen [::1]:8443 ssl proxy_protocol;/' \
|
||||
"$f"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
install_sni() {
|
||||
rewrite_http_443
|
||||
rm -f /etc/nginx/conf.d/spark-acme.conf
|
||||
cp "$HOST_DIR/sparkapi.conf" /etc/nginx/conf.d/sparkapi.conf
|
||||
cp "$HOST_DIR/spark-sni.conf" /etc/nginx/stream.d/spark-sni.conf
|
||||
if grep -RInE 'listen[[:space:]]+(\[::\]:)?443[[:space:]]+ssl' /etc/nginx/conf.d; then
|
||||
echo "http still binds public 443" >&2
|
||||
exit 1
|
||||
fi
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
}
|
||||
|
||||
mkdir -p "$BACKUP"
|
||||
cp -a /etc/nginx "$BACKUP/nginx"
|
||||
echo "backup $BACKUP"
|
||||
|
||||
if ! need_certs; then
|
||||
install_acme
|
||||
issue_certs
|
||||
fi
|
||||
|
||||
if ! need_certs; then
|
||||
echo "certificates missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install_sni
|
||||
echo "nginx sni ready"
|
||||
10
deploy/nginx/nginx.conf
Normal file
10
deploy/nginx/nginx.conf
Normal file
@ -0,0 +1,10 @@
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
include /etc/nginx/stream.conf;
|
||||
include /etc/nginx/http.conf;
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -283,16 +283,16 @@ type TaskPool interface {
|
||||
不学 BFF 握手加密、双版本头、把 Response 当 Request。
|
||||
|
||||
```text
|
||||
TCP → PROXY → TLS 1.2+ → Frame → Protobuf
|
||||
TCP → Nginx TLS → PROXY → gnet Frame → JSON envelope
|
||||
```
|
||||
|
||||
```text
|
||||
Magic u16 | Version u8 | Command u16 | Flags u16
|
||||
RequestID u64 | SequenceID u32 | BodyLength u32
|
||||
Protobuf Body
|
||||
JSON Body {"code","msg","data"}
|
||||
```
|
||||
|
||||
`Flags` 含 `Compress`。Body 超过 512B 再压缩(阈值可配),对齐 BFF `CompressMinSize`。
|
||||
Body **不是 Protobuf**。`Flags` 含 `Compress`。Body 超过 512B 再压缩(阈值可配)。
|
||||
|
||||
`SequenceID` 只匹配请求/响应和 PUSH_ACK。
|
||||
`MaxFrameSize = 64KB`。超大 `BodyLength` 断开。媒体不走 Frame。
|
||||
@ -407,19 +407,21 @@ P2P 好友关系、未拉黑、对方未禁你
|
||||
|
||||
```sql
|
||||
CREATE TABLE messages (
|
||||
id BIGINT PRIMARY KEY,
|
||||
conversation_id BIGINT NOT NULL,
|
||||
sender_id BIGINT NOT NULL,
|
||||
client_msg_id VARCHAR(64) NOT NULL,
|
||||
message_type SMALLINT NOT NULL,
|
||||
content VARBINARY(65535) NOT NULL,
|
||||
status TINYINT NOT NULL DEFAULT 1,
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
id BIGINT PRIMARY KEY COMMENT 'Snowflake MsgId,主键、排序键、同步游标',
|
||||
conversation_id BIGINT NOT NULL COMMENT '所属会话 id',
|
||||
sender_id BIGINT NOT NULL COMMENT '发送者 user_id',
|
||||
client_msg_id VARCHAR(64) NOT NULL COMMENT '客户端消息 id,与 sender+会话 组成幂等键',
|
||||
message_type SMALLINT NOT NULL COMMENT '1文本 2图片 3语音 4视频 5表情 6GIF 7文件 8位置 9系统',
|
||||
content BLOB NOT NULL COMMENT '序列化 JSON 字节,禁止存原始 string;媒体只存对象存储 key',
|
||||
status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 2=已撤回 3=审核隐藏',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '服务端入库时间',
|
||||
UNIQUE KEY uk_sender_conv_client_msg (sender_id, conversation_id, client_msg_id),
|
||||
KEY idx_conversation_id (conversation_id, id)
|
||||
);
|
||||
) COMMENT='消息正文。PK=MsgId;content 为序列化 BLOB';
|
||||
```
|
||||
|
||||
`message_type` 与 `content` 内 JSON 的 `t` 必须一致。正文用 `json.Marshal` 得到的 `[]byte` 写入 BLOB,不要把 Go `string` 当列类型。图片/语音/GIF/文件只存对象存储 key。完整 COMMENT 见 `migrations/0001_init.sql`。
|
||||
|
||||
```sql
|
||||
SELECT * FROM messages
|
||||
WHERE conversation_id = ? AND id > ?
|
||||
@ -458,7 +460,7 @@ CREATE TABLE conversation_members (
|
||||
```
|
||||
|
||||
`last_read_msg_id` 在 MySQL,作为已读真相(重启 Redis 可从这里重建未读)。
|
||||
会话列表不扫这张表的热路径。
|
||||
会话列表不扫这张表的热路径。列 COMMENT 见 `migrations/0001_init.sql`。
|
||||
|
||||
不做 `message_receipts`。
|
||||
|
||||
@ -488,7 +490,7 @@ CREATE TABLE device_sync_cursors (
|
||||
);
|
||||
```
|
||||
|
||||
**不**给每条成功 Push 的新消息写 mailbox。对齐 BFF ICH:邮箱只服务没推上的设备,以及撤回 / 踢人 / 好友这类控制事件。
|
||||
**不**给每条成功 Push 的新消息写 mailbox。对齐 BFF ICH:邮箱只服务没推上的设备,以及撤回 / 踢人 / 好友这类控制事件。列 COMMENT 见 `migrations/0001_init.sql`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
5
examples/android/.gitignore
vendored
Normal file
5
examples/android/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
.gradle/
|
||||
**/build/
|
||||
local.properties
|
||||
*.apk
|
||||
.idea/
|
||||
32
examples/android/app/build.gradle.kts
Normal file
32
examples/android/app/build.gradle.kts
Normal file
@ -0,0 +1,32 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.spark.check"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.spark.check"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 2
|
||||
versionName = "0.1.1"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
21
examples/android/app/src/main/AndroidManifest.xml
Normal file
21
examples/android/app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<application
|
||||
android:label="Spark Check"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:allowBackup="false">
|
||||
<activity
|
||||
android:name=".CheckActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@ -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()
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="#1565C0" />
|
||||
<corners android:radius="16dp" />
|
||||
</shape>
|
||||
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
4
examples/android/build.gradle.kts
Normal file
4
examples/android/build.gradle.kts
Normal file
@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||
}
|
||||
4
examples/android/gradle.properties
Normal file
4
examples/android/gradle.properties
Normal file
@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
BIN
examples/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
examples/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
examples/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
examples/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||
networkTimeout=120000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
examples/android/gradlew
vendored
Executable file
251
examples/android/gradlew
vendored
Executable file
@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
examples/android/gradlew.bat
vendored
Normal file
94
examples/android/gradlew.bat
vendored
Normal file
@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
16
examples/android/settings.gradle.kts
Normal file
16
examples/android/settings.gradle.kts
Normal file
@ -0,0 +1,16 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = "SparkCheck"
|
||||
include(":app")
|
||||
43
go.mod
43
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
|
||||
)
|
||||
|
||||
76
go.sum
76
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=
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
95
internal/bootstrap/health.go
Normal file
95
internal/bootstrap/health.go
Normal file
@ -0,0 +1,95 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"spark-server/internal/infrastructure/mysql"
|
||||
redisx "spark-server/internal/infrastructure/redis"
|
||||
tcp "spark-server/internal/transport/tcp"
|
||||
)
|
||||
|
||||
const (
|
||||
depOK = "ok"
|
||||
depSkipped = "skipped"
|
||||
depDown = "down"
|
||||
depClosed = "closed"
|
||||
)
|
||||
|
||||
// ProbeReport is /readyz data. LB should use HTTP 503 when Ready is false.
|
||||
type ProbeReport struct {
|
||||
Status string `json:"status"`
|
||||
Accepting bool `json:"accepting"`
|
||||
MySQL string `json:"mysql"`
|
||||
Redis string `json:"redis"`
|
||||
ObjectStorage string `json:"object_storage"`
|
||||
TaskPool string `json:"taskpool"`
|
||||
}
|
||||
|
||||
// Probe implements httpx.Health for liveness/readiness.
|
||||
type Probe struct {
|
||||
mysql *mysql.DB
|
||||
redis *redisx.Client
|
||||
objects pinger
|
||||
pool tcp.TaskPool
|
||||
accepting *atomic.Bool
|
||||
requireDeps bool
|
||||
}
|
||||
|
||||
func (p *Probe) Ready(ctx context.Context) (bool, any) {
|
||||
if p == nil {
|
||||
return true, ProbeReport{Status: depOK, Accepting: true, MySQL: depSkipped, Redis: depSkipped, ObjectStorage: depSkipped, TaskPool: depOK}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Second)
|
||||
defer cancel()
|
||||
rep := ProbeReport{
|
||||
Accepting: p.accepting != nil && p.accepting.Load(),
|
||||
MySQL: p.checkStore(ctx, p.mysql),
|
||||
Redis: p.checkStore(ctx, p.redis),
|
||||
ObjectStorage: p.checkOptional(ctx, p.objects),
|
||||
TaskPool: depOK,
|
||||
}
|
||||
ready := rep.Accepting
|
||||
if p.pool != nil && p.pool.Closed() {
|
||||
rep.TaskPool = depClosed
|
||||
ready = false
|
||||
}
|
||||
if rep.MySQL == depDown || rep.Redis == depDown || rep.ObjectStorage == depDown {
|
||||
ready = false
|
||||
}
|
||||
if ready {
|
||||
rep.Status = depOK
|
||||
} else {
|
||||
rep.Status = "not_ready"
|
||||
}
|
||||
return ready, rep
|
||||
}
|
||||
|
||||
type pinger interface {
|
||||
Ready() bool
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
|
||||
func (p *Probe) checkStore(ctx context.Context, s pinger) string {
|
||||
if s == nil || !s.Ready() {
|
||||
if p.requireDeps {
|
||||
return depDown
|
||||
}
|
||||
return depSkipped
|
||||
}
|
||||
if err := s.Ping(ctx); err != nil {
|
||||
return depDown
|
||||
}
|
||||
return depOK
|
||||
}
|
||||
|
||||
func (p *Probe) checkOptional(ctx context.Context, s pinger) string {
|
||||
if s == nil || !s.Ready() {
|
||||
return depSkipped
|
||||
}
|
||||
if err := s.Ping(ctx); err != nil {
|
||||
return depDown
|
||||
}
|
||||
return depOK
|
||||
}
|
||||
85
internal/bootstrap/health_test.go
Normal file
85
internal/bootstrap/health_test.go
Normal file
@ -0,0 +1,85 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseEnvAndProductionValidate(t *testing.T) {
|
||||
t.Setenv("SPARK_ENV", "")
|
||||
t.Setenv("SPARK_JWT_SECRET", "")
|
||||
if parseEnv("") != EnvDev || parseEnv("production") != EnvProd || parseEnv("PROD") != EnvProd {
|
||||
t.Fatal("parseEnv")
|
||||
}
|
||||
dev := &Config{Env: "dev", JWT: JWTConfig{Secret: DefaultJWTSecret}}
|
||||
if err := dev.normalize(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dev.Production() {
|
||||
t.Fatal("dev")
|
||||
}
|
||||
prod := &Config{Env: "prod"}
|
||||
if err := prod.normalize(); err == nil {
|
||||
t.Fatal("prod missing stores")
|
||||
}
|
||||
prod = &Config{
|
||||
Env: "prod",
|
||||
MySQL: MySQLConfig{DSN: "user:pass@tcp(127.0.0.1:3306)/im"},
|
||||
Redis: RedisConfig{Addr: "127.0.0.1:6379"},
|
||||
JWT: JWTConfig{Secret: DefaultJWTSecret},
|
||||
}
|
||||
if err := prod.normalize(); err == nil {
|
||||
t.Fatal("prod default jwt")
|
||||
}
|
||||
prod.JWT.Secret = "long-enough-production-secret"
|
||||
if err := prod.normalize(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !prod.Production() {
|
||||
t.Fatal("prod flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisPasswordFromEnv(t *testing.T) {
|
||||
t.Setenv("SPARK_REDIS_PASSWORD", "from-env")
|
||||
cfg := &Config{Redis: RedisConfig{Addr: "127.0.0.1:6379", Password: "from-yaml"}}
|
||||
if err := cfg.normalize(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Redis.Password != "from-env" {
|
||||
t.Fatalf("password %q", cfg.Redis.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeReadyDevSkipsStores(t *testing.T) {
|
||||
p := &Probe{requireDeps: false}
|
||||
ok, data := p.Ready(t.Context())
|
||||
if ok {
|
||||
t.Fatal("not accepting")
|
||||
}
|
||||
rep := data.(ProbeReport)
|
||||
if rep.MySQL != depSkipped || rep.Redis != depSkipped || rep.Status != "not_ready" {
|
||||
t.Fatalf("%+v", rep)
|
||||
}
|
||||
var accepting atomic.Bool
|
||||
accepting.Store(true)
|
||||
p.accepting = &accepting
|
||||
ok, data = p.Ready(t.Context())
|
||||
if !ok {
|
||||
t.Fatalf("%+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeReadyProdRequiresStores(t *testing.T) {
|
||||
var accepting atomic.Bool
|
||||
accepting.Store(true)
|
||||
p := &Probe{requireDeps: true, accepting: &accepting}
|
||||
ok, data := p.Ready(t.Context())
|
||||
if ok {
|
||||
t.Fatal("want down")
|
||||
}
|
||||
rep := data.(ProbeReport)
|
||||
if rep.MySQL != depDown || rep.Redis != depDown {
|
||||
t.Fatalf("%+v", rep)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
20
internal/infrastructure/logger/body.go
Normal file
20
internal/infrastructure/logger/body.go
Normal file
@ -0,0 +1,20 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
const MaxBody = 4096
|
||||
|
||||
var secretJSON = regexp.MustCompile(`(?i)("(token|access_token|refresh_token|authorization|password|verify_code|upload_url|download_url|secret_key|access_key)"\s*:\s*")[^"]*`)
|
||||
|
||||
func ClipBody(b []byte) string {
|
||||
if len(b) == 0 {
|
||||
return ""
|
||||
}
|
||||
s := secretJSON.ReplaceAllString(string(b), `${1}***`)
|
||||
if len(s) > MaxBody {
|
||||
return s[:MaxBody] + "...<truncated>"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@ -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...)
|
||||
}
|
||||
|
||||
43
internal/infrastructure/logger/logger_test.go
Normal file
43
internal/infrastructure/logger/logger_test.go
Normal file
@ -0,0 +1,43 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
func TestNewWritesRotatingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "app.log")
|
||||
log, closeFn := New(&Options{
|
||||
Level: "info",
|
||||
Console: false,
|
||||
File: FileOptions{Path: path, MaxSizeMB: 1, MaxBackups: 1, MaxAgeDays: 1},
|
||||
})
|
||||
log.Info("hello_file")
|
||||
if err := closeFn(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "hello_file") {
|
||||
t.Fatalf("file content %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLevel(t *testing.T) {
|
||||
if parseLevel("debug") != zapcore.DebugLevel || parseLevel("DEBUG") != zapcore.DebugLevel {
|
||||
t.Fatal("debug")
|
||||
}
|
||||
if parseLevel("error") != zapcore.ErrorLevel {
|
||||
t.Fatal("error")
|
||||
}
|
||||
if parseLevel("info") != zapcore.InfoLevel {
|
||||
t.Fatal("info")
|
||||
}
|
||||
}
|
||||
137
internal/infrastructure/logger/options.go
Normal file
137
internal/infrastructure/logger/options.go
Normal file
@ -0,0 +1,137 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
// Options control zap sinks. File uses lumberjack rotation; Console is for local debug.
|
||||
type Options struct {
|
||||
Level string
|
||||
Encoding string // json | console (stdout only; files are always JSON)
|
||||
Console bool
|
||||
File FileOptions
|
||||
}
|
||||
|
||||
// FileOptions is lumberjack rotation for a JSON log file.
|
||||
type FileOptions struct {
|
||||
Path string
|
||||
MaxSizeMB int
|
||||
MaxBackups int
|
||||
MaxAgeDays int
|
||||
Compress bool
|
||||
}
|
||||
|
||||
type closer struct {
|
||||
log *zap.Logger
|
||||
lj *lumberjack.Logger
|
||||
}
|
||||
|
||||
func (c *closer) Close() error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
if c.log != nil {
|
||||
_ = c.log.Sync()
|
||||
}
|
||||
if c.lj != nil {
|
||||
return c.lj.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// New builds a zap logger. Close the returned func on shutdown to flush and close the file sink.
|
||||
func New(o *Options) (*zap.Logger, func() error) {
|
||||
if o == nil {
|
||||
o = &Options{}
|
||||
}
|
||||
applyDefaults(o)
|
||||
level := parseLevel(o.Level)
|
||||
var lj *lumberjack.Logger
|
||||
cores := make([]zapcore.Core, 0, 2)
|
||||
if o.Console {
|
||||
cores = append(cores, zapcore.NewCore(stdoutEncoder(o.Encoding), zapcore.AddSync(os.Stdout), level))
|
||||
}
|
||||
if o.File.Path != "" {
|
||||
lj = newLumberjack(o.File)
|
||||
cores = append(cores, zapcore.NewCore(jsonEncoder(), zapcore.AddSync(lj), level))
|
||||
}
|
||||
if len(cores) == 0 {
|
||||
cores = append(cores, zapcore.NewCore(jsonEncoder(), zapcore.AddSync(os.Stdout), level))
|
||||
}
|
||||
log := zap.New(zapcore.NewTee(cores...), zap.AddCaller())
|
||||
c := &closer{log: log, lj: lj}
|
||||
return log, c.Close
|
||||
}
|
||||
|
||||
func applyDefaults(o *Options) {
|
||||
if o.Level == "" {
|
||||
o.Level = "info"
|
||||
}
|
||||
if o.Encoding == "" {
|
||||
if strings.EqualFold(o.Level, "debug") {
|
||||
o.Encoding = "console"
|
||||
} else {
|
||||
o.Encoding = "json"
|
||||
}
|
||||
}
|
||||
if !o.Console && o.File.Path == "" {
|
||||
o.Console = true
|
||||
}
|
||||
if o.File.Path != "" {
|
||||
if o.File.MaxSizeMB <= 0 {
|
||||
o.File.MaxSizeMB = 100
|
||||
}
|
||||
if o.File.MaxBackups <= 0 {
|
||||
o.File.MaxBackups = 7
|
||||
}
|
||||
if o.File.MaxAgeDays <= 0 {
|
||||
o.File.MaxAgeDays = 7
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseLevel(s string) zapcore.Level {
|
||||
switch strings.ToLower(s) {
|
||||
case "debug":
|
||||
return zapcore.DebugLevel
|
||||
case "warn", "warning":
|
||||
return zapcore.WarnLevel
|
||||
case "error":
|
||||
return zapcore.ErrorLevel
|
||||
default:
|
||||
return zapcore.InfoLevel
|
||||
}
|
||||
}
|
||||
|
||||
func jsonEncoder() zapcore.Encoder {
|
||||
cfg := zap.NewProductionEncoderConfig()
|
||||
cfg.TimeKey = "ts"
|
||||
cfg.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||
cfg.MessageKey = "msg"
|
||||
return zapcore.NewJSONEncoder(cfg)
|
||||
}
|
||||
|
||||
func stdoutEncoder(encoding string) zapcore.Encoder {
|
||||
if strings.EqualFold(encoding, "json") {
|
||||
return jsonEncoder()
|
||||
}
|
||||
cfg := zap.NewDevelopmentEncoderConfig()
|
||||
cfg.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||
return zapcore.NewConsoleEncoder(cfg)
|
||||
}
|
||||
|
||||
func newLumberjack(f FileOptions) *lumberjack.Logger {
|
||||
return &lumberjack.Logger{
|
||||
Filename: f.Path,
|
||||
MaxSize: f.MaxSizeMB,
|
||||
MaxBackups: f.MaxBackups,
|
||||
MaxAge: f.MaxAgeDays,
|
||||
Compress: f.Compress,
|
||||
LocalTime: true,
|
||||
}
|
||||
}
|
||||
102
internal/infrastructure/mysql/gorm_logger.go
Normal file
102
internal/infrastructure/mysql/gorm_logger.go
Normal file
@ -0,0 +1,102 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
|
||||
"spark-server/internal/infrastructure/logger"
|
||||
"spark-server/internal/pkg/reqctx"
|
||||
)
|
||||
|
||||
type gormZap struct {
|
||||
base *zap.Logger
|
||||
level gormlogger.LogLevel
|
||||
slow time.Duration
|
||||
}
|
||||
|
||||
func newGormLogger(base *zap.Logger, slow time.Duration, level gormlogger.LogLevel) *gormZap {
|
||||
if base == nil {
|
||||
base = zap.NewNop()
|
||||
}
|
||||
if slow <= 0 {
|
||||
slow = 200 * time.Millisecond
|
||||
}
|
||||
if level == 0 {
|
||||
level = gormlogger.Warn
|
||||
}
|
||||
return &gormZap{base: base.Named("gorm"), level: level, slow: slow}
|
||||
}
|
||||
|
||||
func (l *gormZap) LogMode(level gormlogger.LogLevel) gormlogger.Interface {
|
||||
cp := *l
|
||||
cp.level = level
|
||||
return &cp
|
||||
}
|
||||
|
||||
func (l *gormZap) Info(ctx context.Context, msg string, data ...interface{}) {
|
||||
if l.level < gormlogger.Info {
|
||||
return
|
||||
}
|
||||
l.zap(ctx).Sugar().Infof(msg, data...)
|
||||
}
|
||||
|
||||
func (l *gormZap) Warn(ctx context.Context, msg string, data ...interface{}) {
|
||||
if l.level < gormlogger.Warn {
|
||||
return
|
||||
}
|
||||
l.zap(ctx).Sugar().Warnf(msg, data...)
|
||||
}
|
||||
|
||||
func (l *gormZap) Error(ctx context.Context, msg string, data ...interface{}) {
|
||||
if l.level < gormlogger.Error {
|
||||
return
|
||||
}
|
||||
l.zap(ctx).Sugar().Errorf(msg, data...)
|
||||
}
|
||||
|
||||
func (l *gormZap) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||
if l.level <= gormlogger.Silent {
|
||||
return
|
||||
}
|
||||
elapsed := time.Since(begin)
|
||||
sql, rows := fc()
|
||||
log := l.zap(ctx)
|
||||
switch {
|
||||
case err != nil && !errors.Is(err, gorm.ErrRecordNotFound) && l.level >= gormlogger.Error:
|
||||
log.Error("sql", zap.Error(err), zap.String("sql", sql), zap.Duration("elapsed", elapsed), zap.Int64("rows", rows))
|
||||
case elapsed >= l.slow && l.level >= gormlogger.Warn:
|
||||
log.Warn("sql_slow", zap.String("sql", sql), zap.Duration("elapsed", elapsed), zap.Int64("rows", rows))
|
||||
case l.level >= gormlogger.Info:
|
||||
log.Info("sql", zap.String("sql", sql), zap.Duration("elapsed", elapsed), zap.Int64("rows", rows))
|
||||
}
|
||||
}
|
||||
|
||||
func (l *gormZap) zap(ctx context.Context) *zap.Logger {
|
||||
if reqctx.From(ctx) != nil {
|
||||
return logger.From(ctx).Named("gorm")
|
||||
}
|
||||
return l.base
|
||||
}
|
||||
|
||||
func parseGormLevel(s, zapLevel string) gormlogger.LogLevel {
|
||||
switch s {
|
||||
case "silent":
|
||||
return gormlogger.Silent
|
||||
case "error":
|
||||
return gormlogger.Error
|
||||
case "warn", "warning":
|
||||
return gormlogger.Warn
|
||||
case "info":
|
||||
return gormlogger.Info
|
||||
default:
|
||||
if zapLevel == "debug" {
|
||||
return gormlogger.Info
|
||||
}
|
||||
return gormlogger.Warn
|
||||
}
|
||||
}
|
||||
60
internal/infrastructure/mysql/gorm_logger_test.go
Normal file
60
internal/infrastructure/mysql/gorm_logger_test.go
Normal file
@ -0,0 +1,60 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func TestGormLoggerTrace(t *testing.T) {
|
||||
core, logs := observer.New(zapcore.DebugLevel)
|
||||
z := zap.New(core)
|
||||
gl := newGormLogger(z, time.Millisecond, gormlogger.Info)
|
||||
gl.Trace(context.Background(), time.Now().Add(-10*time.Millisecond), func() (string, int64) {
|
||||
return "SELECT 1", 1
|
||||
}, nil)
|
||||
if logs.FilterMessage("sql_slow").Len() != 1 {
|
||||
t.Fatalf("want sql_slow, got %+v", logs.All())
|
||||
}
|
||||
gl.Trace(context.Background(), time.Now(), func() (string, int64) {
|
||||
return "SELECT 2", 1
|
||||
}, nil)
|
||||
if logs.FilterMessage("sql").Len() < 1 {
|
||||
t.Fatalf("want sql, got %+v", logs.All())
|
||||
}
|
||||
gl.Trace(context.Background(), time.Now(), func() (string, int64) {
|
||||
return "SELECT 3", 0
|
||||
}, errors.New("boom"))
|
||||
if logs.FilterMessage("sql").FilterLevelExact(zapcore.ErrorLevel).Len() < 1 {
|
||||
t.Fatalf("want sql error, got %+v", logs.All())
|
||||
}
|
||||
gl.Trace(context.Background(), time.Now(), func() (string, int64) {
|
||||
return "SELECT 4", 0
|
||||
}, gorm.ErrRecordNotFound)
|
||||
}
|
||||
|
||||
func TestParseGormLevel(t *testing.T) {
|
||||
if parseGormLevel("", "debug") != gormlogger.Info {
|
||||
t.Fatal("debug follows zap")
|
||||
}
|
||||
if parseGormLevel("", "info") != gormlogger.Warn {
|
||||
t.Fatal("info default warn")
|
||||
}
|
||||
if parseGormLevel("silent", "debug") != gormlogger.Silent {
|
||||
t.Fatal("silent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenEmptyDSN(t *testing.T) {
|
||||
db, err := Open(zap.NewNop(), &Options{})
|
||||
if err != nil || db.Ready() {
|
||||
t.Fatalf("empty dsn %v ready=%v", err, db.Ready())
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
157
internal/infrastructure/objectstore/ops.go
Normal file
157
internal/infrastructure/objectstore/ops.go
Normal file
@ -0,0 +1,157 @@
|
||||
package objectstore
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/aws/smithy-go"
|
||||
smithyhttp "github.com/aws/smithy-go/transport/http"
|
||||
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
const maxObjectBytes = 4 << 20
|
||||
|
||||
func (s *Store) PresignPut(ctx context.Context, key, contentType string, ttl time.Duration) (string, error) {
|
||||
if !s.Ready() {
|
||||
return "", errs.ErrAvailable
|
||||
}
|
||||
in := &s3.PutObjectInput{Bucket: aws.String(s.bucket), Key: aws.String(key)}
|
||||
if contentType != "" {
|
||||
in.ContentType = aws.String(contentType)
|
||||
}
|
||||
out, err := s.presign.PresignPutObject(ctx, in, s3.WithPresignExpires(ttl))
|
||||
if err != nil {
|
||||
return "", mapObjectErr(err)
|
||||
}
|
||||
return out.URL, nil
|
||||
}
|
||||
|
||||
func (s *Store) PresignGet(ctx context.Context, key string, ttl time.Duration) (string, error) {
|
||||
if !s.Ready() {
|
||||
return "", errs.ErrAvailable
|
||||
}
|
||||
out, err := s.presign.PresignGetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
}, s3.WithPresignExpires(ttl))
|
||||
if err != nil {
|
||||
return "", mapObjectErr(err)
|
||||
}
|
||||
return out.URL, nil
|
||||
}
|
||||
|
||||
func (s *Store) Get(ctx context.Context, key string) ([]byte, error) {
|
||||
if !s.Ready() {
|
||||
return nil, errs.ErrAvailable
|
||||
}
|
||||
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, mapObjectErr(err)
|
||||
}
|
||||
defer func() { _ = out.Body.Close() }()
|
||||
data, err := io.ReadAll(io.LimitReader(out.Body, maxObjectBytes+1))
|
||||
if err != nil {
|
||||
return nil, mapObjectErr(err)
|
||||
}
|
||||
if len(data) > maxObjectBytes {
|
||||
return nil, errs.ErrInvalidArgument
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *Store) Put(ctx context.Context, key string, body []byte, contentType string) error {
|
||||
if !s.Ready() {
|
||||
return errs.ErrAvailable
|
||||
}
|
||||
if len(body) == 0 || len(body) > maxObjectBytes {
|
||||
return errs.ErrInvalidArgument
|
||||
}
|
||||
in := &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
Body: bytes.NewReader(body),
|
||||
ContentLength: aws.Int64(int64(len(body))),
|
||||
}
|
||||
if contentType != "" {
|
||||
in.ContentType = aws.String(contentType)
|
||||
}
|
||||
if _, err := s.client.PutObject(ctx, in); err != nil {
|
||||
return mapObjectErr(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Copy(ctx context.Context, src, dst string) error {
|
||||
if !s.Ready() {
|
||||
return errs.ErrAvailable
|
||||
}
|
||||
_, err := s.client.CopyObject(ctx, &s3.CopyObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(dst),
|
||||
CopySource: aws.String(s.bucket + "/" + src),
|
||||
})
|
||||
if err != nil {
|
||||
return mapObjectErr(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Delete(ctx context.Context, key string) error {
|
||||
if !s.Ready() {
|
||||
return errs.ErrAvailable
|
||||
}
|
||||
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return mapObjectErr(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapObjectErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if app, ok := errs.AsAppError(err); ok {
|
||||
return app
|
||||
}
|
||||
if isMissingObject(err) {
|
||||
return errs.ErrMediaExpired.WithError("objectstore", err)
|
||||
}
|
||||
return errs.ErrOperatFail.WithError("objectstore", err)
|
||||
}
|
||||
|
||||
func isMissingObject(err error) bool {
|
||||
var nf *types.NotFound
|
||||
var nk *types.NoSuchKey
|
||||
var nb *types.NoSuchBucket
|
||||
if errors.As(err, &nf) || errors.As(err, &nk) || errors.As(err, &nb) {
|
||||
return true
|
||||
}
|
||||
var api smithy.APIError
|
||||
if errors.As(err, &api) {
|
||||
switch strings.ToLower(api.ErrorCode()) {
|
||||
case "nosuchkey", "nosuchobject", "notfound", "nosuchbucket":
|
||||
return true
|
||||
}
|
||||
}
|
||||
var httpErr *smithyhttp.ResponseError
|
||||
if errors.As(err, &httpErr) && httpErr.HTTPStatusCode() == http.StatusNotFound {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
85
internal/infrastructure/objectstore/policy.go
Normal file
85
internal/infrastructure/objectstore/policy.go
Normal file
@ -0,0 +1,85 @@
|
||||
package objectstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *Store) ensureBucket(ctx context.Context, region string, chatExpireDays int) error {
|
||||
_, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.bucket)})
|
||||
if err != nil {
|
||||
if !isMissingObject(err) {
|
||||
return fmt.Errorf("objectstore ping: %w", err)
|
||||
}
|
||||
if err := s.createBucket(ctx, region); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.applyLifecycle(ctx, chatExpireDays)
|
||||
s.applyCORS(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) createBucket(ctx context.Context, region string) error {
|
||||
in := &s3.CreateBucketInput{Bucket: aws.String(s.bucket)}
|
||||
if region != "" && region != defaultRegion {
|
||||
in.CreateBucketConfiguration = &types.CreateBucketConfiguration{
|
||||
LocationConstraint: types.BucketLocationConstraint(region),
|
||||
}
|
||||
}
|
||||
if _, err := s.client.CreateBucket(ctx, in); err != nil {
|
||||
var owned *types.BucketAlreadyOwnedByYou
|
||||
var exists *types.BucketAlreadyExists
|
||||
if errors.As(err, &owned) || errors.As(err, &exists) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("objectstore make bucket: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) applyLifecycle(ctx context.Context, days int) {
|
||||
if days < 1 {
|
||||
return
|
||||
}
|
||||
_, err := s.client.PutBucketLifecycleConfiguration(ctx, &s3.PutBucketLifecycleConfigurationInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
LifecycleConfiguration: &types.BucketLifecycleConfiguration{
|
||||
Rules: []types.LifecycleRule{{
|
||||
ID: aws.String("expire-chat"),
|
||||
Status: types.ExpirationStatusEnabled,
|
||||
Filter: &types.LifecycleRuleFilter{Prefix: aws.String("chat/")},
|
||||
Expiration: &types.LifecycleExpiration{
|
||||
Days: aws.Int32(int32(days)),
|
||||
},
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Warn("objectstore lifecycle", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) applyCORS(ctx context.Context) {
|
||||
_, err := s.client.PutBucketCors(ctx, &s3.PutBucketCorsInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
CORSConfiguration: &types.CORSConfiguration{
|
||||
CORSRules: []types.CORSRule{{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "PUT", "HEAD"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
ExposeHeaders: []string{"ETag", "Content-Length"},
|
||||
MaxAgeSeconds: aws.Int32(3600),
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Warn("objectstore cors", zap.Error(err))
|
||||
}
|
||||
}
|
||||
109
internal/infrastructure/objectstore/store.go
Normal file
109
internal/infrastructure/objectstore/store.go
Normal file
@ -0,0 +1,109 @@
|
||||
// Package objectstore talks to S3-compatible object storage (RustFS) via AWS SDK v2.
|
||||
package objectstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRegion = "us-east-1"
|
||||
defaultTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// Store is a RustFS / S3 client. Nil or !Ready means object storage is not configured.
|
||||
type Store struct {
|
||||
client *s3.Client
|
||||
presign *s3.PresignClient
|
||||
bucket string
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// Options configure Open. Empty Endpoint skips the connection.
|
||||
type Options struct {
|
||||
Endpoint string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
Bucket string
|
||||
Region string
|
||||
UseSSL bool
|
||||
ChatExpireDays int
|
||||
Log *zap.Logger
|
||||
}
|
||||
|
||||
func Open(o *Options) (*Store, error) {
|
||||
if o == nil || strings.TrimSpace(o.Endpoint) == "" {
|
||||
return &Store{}, nil
|
||||
}
|
||||
host, secure, err := parseEndpoint(o.Endpoint, o.UseSSL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bucket := strings.TrimSpace(o.Bucket)
|
||||
if bucket == "" {
|
||||
bucket = "spark"
|
||||
}
|
||||
region := strings.TrimSpace(o.Region)
|
||||
if region == "" {
|
||||
region = defaultRegion
|
||||
}
|
||||
scheme := "http"
|
||||
if secure {
|
||||
scheme = "https"
|
||||
}
|
||||
client := newS3Client(region, scheme+"://"+host, o.AccessKey, o.SecretKey)
|
||||
s := &Store{client: client, presign: s3.NewPresignClient(client), bucket: bucket, log: o.Log}
|
||||
if s.log == nil {
|
||||
s.log = zap.NewNop()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
|
||||
defer cancel()
|
||||
if err := s.ensureBucket(ctx, region, o.ChatExpireDays); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Ready() bool {
|
||||
return s != nil && s.client != nil && s.presign != nil && s.bucket != ""
|
||||
}
|
||||
|
||||
func (s *Store) Ping(ctx context.Context) error {
|
||||
if !s.Ready() {
|
||||
return nil
|
||||
}
|
||||
_, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.bucket)})
|
||||
return err
|
||||
}
|
||||
|
||||
func newS3Client(region, baseURL, access, secret string) *s3.Client {
|
||||
return s3.New(s3.Options{
|
||||
Region: region,
|
||||
Credentials: credentials.NewStaticCredentialsProvider(access, secret, ""),
|
||||
BaseEndpoint: aws.String(baseURL),
|
||||
UsePathStyle: true,
|
||||
RequestChecksumCalculation: aws.RequestChecksumCalculationWhenRequired,
|
||||
ResponseChecksumValidation: aws.ResponseChecksumValidationWhenRequired,
|
||||
})
|
||||
}
|
||||
|
||||
func parseEndpoint(raw string, useSSL bool) (host string, secure bool, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
switch {
|
||||
case raw == "":
|
||||
return "", false, fmt.Errorf("objectstore: empty endpoint")
|
||||
case strings.HasPrefix(raw, "https://"):
|
||||
return strings.TrimPrefix(raw, "https://"), true, nil
|
||||
case strings.HasPrefix(raw, "http://"):
|
||||
return strings.TrimPrefix(raw, "http://"), false, nil
|
||||
default:
|
||||
return raw, useSSL, nil
|
||||
}
|
||||
}
|
||||
104
internal/infrastructure/objectstore/store_test.go
Normal file
104
internal/infrastructure/objectstore/store_test.go
Normal file
@ -0,0 +1,104 @@
|
||||
package objectstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
func TestParseEndpoint(t *testing.T) {
|
||||
host, secure, err := parseEndpoint("http://47.236.66.9:9000", true)
|
||||
if err != nil || host != "47.236.66.9:9000" || secure {
|
||||
t.Fatalf("%s %v %v", host, secure, err)
|
||||
}
|
||||
host, secure, err = parseEndpoint("47.236.66.9:9000", false)
|
||||
if err != nil || host != "47.236.66.9:9000" || secure {
|
||||
t.Fatalf("plain %s %v %v", host, secure, err)
|
||||
}
|
||||
host, secure, err = parseEndpoint("https://fs.example.com", false)
|
||||
if err != nil || host != "fs.example.com" || !secure {
|
||||
t.Fatalf("https %s %v %v", host, secure, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenEmpty(t *testing.T) {
|
||||
s, err := Open(&Options{})
|
||||
if err != nil || s.Ready() {
|
||||
t.Fatalf("ready=%v err=%v", s.Ready(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresignLocal(t *testing.T) {
|
||||
client := newS3Client(defaultRegion, "http://127.0.0.1:9000", "ak", "sk")
|
||||
s := &Store{client: client, presign: s3.NewPresignClient(client), bucket: "spark"}
|
||||
u, err := s.PresignPut(t.Context(), "chat/a.jpg", "image/jpeg", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(u, "127.0.0.1:9000/spark/chat/a.jpg") {
|
||||
t.Fatalf("%s", u)
|
||||
}
|
||||
g, err := s.PresignGet(t.Context(), "perm/avatar/1.jpg", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(g, "perm/avatar/1.jpg") {
|
||||
t.Fatalf("%s", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPutNotReady(t *testing.T) {
|
||||
s, err := Open(&Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Get(t.Context(), "perm/a"); !errors.Is(err, errs.ErrAvailable) {
|
||||
t.Fatalf("get %v", err)
|
||||
}
|
||||
if err := s.Put(t.Context(), "perm/a", []byte("x"), "image/jpeg"); !errors.Is(err, errs.ErrAvailable) {
|
||||
t.Fatalf("put %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapObjectErr(t *testing.T) {
|
||||
err := mapObjectErr(&types.NoSuchKey{Message: aws.String("gone")})
|
||||
if !errors.Is(err, errs.ErrMediaExpired) {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
err = mapObjectErr(errors.New("boom"))
|
||||
if !errors.Is(err, errs.ErrOperatFail) {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenLive(t *testing.T) {
|
||||
if os.Getenv("SPARK_S3_LIVE") == "" {
|
||||
t.Skip("set SPARK_S3_LIVE=1 to hit RustFS")
|
||||
}
|
||||
s, err := Open(&Options{
|
||||
Endpoint: "47.236.66.9:9000",
|
||||
AccessKey: os.Getenv("SPARK_S3_ACCESS_KEY"),
|
||||
SecretKey: os.Getenv("SPARK_S3_SECRET_KEY"),
|
||||
Bucket: "spark",
|
||||
ChatExpireDays: 365,
|
||||
Log: zap.NewNop(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.Ready() {
|
||||
t.Fatal("not ready")
|
||||
}
|
||||
if err := s.Ping(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
14
internal/infrastructure/redis/redis_test.go
Normal file
14
internal/infrastructure/redis/redis_test.go
Normal file
@ -0,0 +1,14 @@
|
||||
package redisx
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestOpenEmptyAddrSkips(t *testing.T) {
|
||||
c, err := Open(nil)
|
||||
if err != nil || c.Ready() {
|
||||
t.Fatalf("nil options: %v ready=%v", err, c.Ready())
|
||||
}
|
||||
c, err = Open(&Options{})
|
||||
if err != nil || c.Ready() {
|
||||
t.Fatalf("empty addr: %v ready=%v", err, c.Ready())
|
||||
}
|
||||
}
|
||||
23
internal/module/auth/entity/principal.go
Normal file
23
internal/module/auth/entity/principal.go
Normal file
@ -0,0 +1,23 @@
|
||||
// Package entity holds auth domain types shared by HTTP Bearer and TCP AUTH.
|
||||
package entity
|
||||
|
||||
import "time"
|
||||
|
||||
// Principal is the authenticated identity.
|
||||
type Principal struct {
|
||||
UserID int64
|
||||
DeviceID uint64
|
||||
JTI string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// IssuedToken is the login / refresh `data` payload.
|
||||
type IssuedToken struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
RefreshExpiresIn int64 `json:"refresh_expires_in"`
|
||||
UserID int64 `json:"user_id"`
|
||||
DeviceID uint64 `json:"device_id"`
|
||||
SparkID string `json:"spark_id"`
|
||||
}
|
||||
91
internal/module/auth/logic/denylist.go
Normal file
91
internal/module/auth/logic/denylist.go
Normal file
@ -0,0 +1,91 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
// Denylist stores revoked JWT ids until their original expiry.
|
||||
type Denylist interface {
|
||||
Has(ctx context.Context, jti string) (bool, error)
|
||||
Add(ctx context.Context, jti string, ttl time.Duration) error
|
||||
}
|
||||
|
||||
// MemoryDenylist is process-local. Used in tests and when Redis is not configured.
|
||||
type MemoryDenylist struct {
|
||||
mu sync.Mutex
|
||||
items map[string]int64 // jti → expiry unix nano
|
||||
}
|
||||
|
||||
func NewMemoryDenylist() *MemoryDenylist {
|
||||
return &MemoryDenylist{items: make(map[string]int64)}
|
||||
}
|
||||
|
||||
func (d *MemoryDenylist) Has(_ context.Context, jti string) (bool, error) {
|
||||
if jti == "" {
|
||||
return false, nil
|
||||
}
|
||||
now := time.Now().UnixNano()
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
exp, ok := d.items[jti]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
if now >= exp {
|
||||
delete(d.items, jti)
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (d *MemoryDenylist) Add(_ context.Context, jti string, ttl time.Duration) error {
|
||||
if jti == "" || ttl <= 0 {
|
||||
return nil
|
||||
}
|
||||
d.mu.Lock()
|
||||
d.items[jti] = time.Now().Add(ttl).UnixNano()
|
||||
d.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// RedisDenylist uses SETEX denylist:{jti} with remaining token TTL.
|
||||
type RedisDenylist struct {
|
||||
rdb *redis.Client
|
||||
prefix string
|
||||
}
|
||||
|
||||
func NewRedisDenylist(rdb *redis.Client, prefix string) *RedisDenylist {
|
||||
if prefix == "" {
|
||||
prefix = "denylist:"
|
||||
}
|
||||
return &RedisDenylist{rdb: rdb, prefix: prefix}
|
||||
}
|
||||
|
||||
func (d *RedisDenylist) key(jti string) string { return d.prefix + jti }
|
||||
|
||||
func (d *RedisDenylist) Has(ctx context.Context, jti string) (bool, error) {
|
||||
if d == nil || d.rdb == nil {
|
||||
return false, errs.ErrAvailable.WithInnerMsg("jwt denylist redis not ready")
|
||||
}
|
||||
n, err := d.rdb.Exists(ctx, d.key(jti)).Result()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
func (d *RedisDenylist) Add(ctx context.Context, jti string, ttl time.Duration) error {
|
||||
if d == nil || d.rdb == nil {
|
||||
return errs.ErrAvailable.WithInnerMsg("jwt denylist redis not ready")
|
||||
}
|
||||
if jti == "" || ttl <= 0 {
|
||||
return nil
|
||||
}
|
||||
return d.rdb.Set(ctx, d.key(jti), "1", ttl).Err()
|
||||
}
|
||||
308
internal/module/auth/logic/jwt.go
Normal file
308
internal/module/auth/logic/jwt.go
Normal file
@ -0,0 +1,308 @@
|
||||
// Package logic is the auth application use-case layer (JWT, login, denylist).
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
authentity "spark-server/internal/module/auth/entity"
|
||||
"spark-server/internal/pkg/errs"
|
||||
"spark-server/internal/pkg/reqctx"
|
||||
)
|
||||
|
||||
// Service validates an access token. HTTP middleware and TCP AUTH share this.
|
||||
type Service interface {
|
||||
Authenticate(ctx context.Context, token string) (*authentity.Principal, error)
|
||||
}
|
||||
|
||||
// Issuer mints, refreshes, and revokes access tokens.
|
||||
type Issuer interface {
|
||||
Issue(ctx context.Context, userID int64, deviceID uint64) (*authentity.IssuedToken, error)
|
||||
Refresh(ctx context.Context, refreshToken string) (*authentity.IssuedToken, error)
|
||||
Logout(ctx context.Context, userID int64, deviceID uint64, accessJTI string, accessExp time.Time) error
|
||||
Revoke(ctx context.Context, token string) error
|
||||
}
|
||||
|
||||
// TokenService is JWT: authenticate, issue, and revoke.
|
||||
type TokenService interface {
|
||||
Service
|
||||
Issuer
|
||||
}
|
||||
|
||||
const (
|
||||
defaultIssuer = "spark-im"
|
||||
defaultTTL = 7 * 24 * time.Hour
|
||||
defaultRefreshTTL = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// Options configure HS256 JWT issuance and validation.
|
||||
type Options struct {
|
||||
Secret string
|
||||
Issuer string
|
||||
TTL time.Duration
|
||||
RefreshTTL time.Duration
|
||||
Denylist Denylist
|
||||
Pairs PairStore
|
||||
Clock func() time.Time
|
||||
}
|
||||
|
||||
type claims struct {
|
||||
DeviceID uint64 `json:"did"`
|
||||
Use string `json:"use,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// JWT implements TokenService with HS256 and an optional jti denylist.
|
||||
type JWT struct {
|
||||
secret []byte
|
||||
issuer string
|
||||
ttl time.Duration
|
||||
refreshTTL time.Duration
|
||||
denylist Denylist
|
||||
pairs PairStore
|
||||
clock func() time.Time
|
||||
}
|
||||
|
||||
func NewJWT(opt *Options) (*JWT, error) {
|
||||
if opt == nil || strings.TrimSpace(opt.Secret) == "" {
|
||||
return nil, errs.ErrInner.WithInnerMsg("jwt: secret is required")
|
||||
}
|
||||
issuer := opt.Issuer
|
||||
if issuer == "" {
|
||||
issuer = defaultIssuer
|
||||
}
|
||||
ttl := opt.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = defaultTTL
|
||||
}
|
||||
refreshTTL := opt.RefreshTTL
|
||||
if refreshTTL <= 0 {
|
||||
refreshTTL = defaultRefreshTTL
|
||||
}
|
||||
dl := opt.Denylist
|
||||
if dl == nil {
|
||||
dl = NewMemoryDenylist()
|
||||
}
|
||||
pairs := opt.Pairs
|
||||
if pairs == nil {
|
||||
pairs = NewMemoryPairs()
|
||||
}
|
||||
clock := opt.Clock
|
||||
if clock == nil {
|
||||
clock = time.Now
|
||||
}
|
||||
return &JWT{
|
||||
secret: []byte(opt.Secret),
|
||||
issuer: issuer,
|
||||
ttl: ttl,
|
||||
refreshTTL: refreshTTL,
|
||||
denylist: dl,
|
||||
pairs: pairs,
|
||||
clock: clock,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (j *JWT) Issue(ctx context.Context, userID int64, deviceID uint64) (*authentity.IssuedToken, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, errs.FromRPCError("jwt", err)
|
||||
}
|
||||
if userID <= 0 || deviceID == 0 {
|
||||
return nil, errs.ErrInvalidArgument
|
||||
}
|
||||
access, err := j.sign(userID, deviceID, tokenUseAccess, j.ttl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refresh, err := j.sign(userID, deviceID, tokenUseRefresh, j.refreshTTL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := j.replacePair(ctx, userID, deviceID, access.jti, refresh.jti); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &authentity.IssuedToken{
|
||||
AccessToken: access.raw,
|
||||
RefreshToken: refresh.raw,
|
||||
ExpiresIn: int64(j.ttl / time.Second),
|
||||
RefreshExpiresIn: int64(j.refreshTTL / time.Second),
|
||||
UserID: userID,
|
||||
DeviceID: deviceID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type signedToken struct {
|
||||
raw string
|
||||
jti string
|
||||
}
|
||||
|
||||
func (j *JWT) sign(userID int64, deviceID uint64, use string, ttl time.Duration) (signedToken, error) {
|
||||
now := j.clock()
|
||||
jti := reqctx.NewID()
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, &claims{
|
||||
DeviceID: deviceID,
|
||||
Use: use,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: strconv.FormatInt(userID, 10),
|
||||
Issuer: j.issuer,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
ID: jti,
|
||||
},
|
||||
})
|
||||
raw, err := tok.SignedString(j.secret)
|
||||
if err != nil {
|
||||
return signedToken{}, errs.ErrInner.WithError("jwt", err)
|
||||
}
|
||||
return signedToken{raw: raw, jti: jti}, nil
|
||||
}
|
||||
|
||||
func (j *JWT) replacePair(ctx context.Context, userID int64, deviceID uint64, accessJTI, refreshJTI string) error {
|
||||
old, err := j.pairs.Get(ctx, userID, deviceID)
|
||||
if err != nil {
|
||||
return errs.ErrAvailable.WithError("jwt-refresh", err)
|
||||
}
|
||||
if old.Access != "" {
|
||||
_ = j.denylist.Add(ctx, old.Access, j.ttl)
|
||||
}
|
||||
if old.Refresh != "" {
|
||||
_ = j.denylist.Add(ctx, old.Refresh, j.refreshTTL)
|
||||
}
|
||||
if err := j.pairs.Put(ctx, userID, deviceID, TokenPair{Access: accessJTI, Refresh: refreshJTI}, j.refreshTTL); err != nil {
|
||||
return errs.ErrAvailable.WithError("jwt-refresh", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *JWT) Refresh(ctx context.Context, refreshToken string) (*authentity.IssuedToken, error) {
|
||||
c, err := j.parse(refreshToken, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Use != tokenUseRefresh {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
denied, err := j.denylist.Has(ctx, c.ID)
|
||||
if err != nil {
|
||||
return nil, errs.ErrAvailable.WithError("jwt-denylist", err)
|
||||
}
|
||||
if denied {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
uid, err := strconv.ParseInt(c.Subject, 10, 64)
|
||||
if err != nil || uid <= 0 || c.DeviceID == 0 {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
stored, err := j.pairs.Get(ctx, uid, c.DeviceID)
|
||||
if err != nil {
|
||||
return nil, errs.ErrAvailable.WithError("jwt-refresh", err)
|
||||
}
|
||||
if stored.Refresh == "" || stored.Refresh != c.ID {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
return j.Issue(ctx, uid, c.DeviceID)
|
||||
}
|
||||
|
||||
func (j *JWT) Logout(ctx context.Context, userID int64, deviceID uint64, accessJTI string, accessExp time.Time) error {
|
||||
if accessJTI != "" {
|
||||
remain := accessExp.Sub(j.clock())
|
||||
if remain > 0 {
|
||||
if err := j.denylist.Add(ctx, accessJTI, remain); err != nil {
|
||||
return errs.ErrAvailable.WithError("jwt-denylist", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
old, err := j.pairs.Get(ctx, userID, deviceID)
|
||||
if err != nil {
|
||||
return errs.ErrAvailable.WithError("jwt-refresh", err)
|
||||
}
|
||||
if old.Access != "" && old.Access != accessJTI {
|
||||
_ = j.denylist.Add(ctx, old.Access, j.ttl)
|
||||
}
|
||||
if old.Refresh != "" {
|
||||
_ = j.denylist.Add(ctx, old.Refresh, j.refreshTTL)
|
||||
}
|
||||
if err := j.pairs.Delete(ctx, userID, deviceID); err != nil {
|
||||
return errs.ErrAvailable.WithError("jwt-refresh", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *JWT) Authenticate(ctx context.Context, token string) (*authentity.Principal, error) {
|
||||
c, err := j.parse(token, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
denied, err := j.denylist.Has(ctx, c.ID)
|
||||
if err != nil {
|
||||
return nil, errs.ErrAvailable.WithError("jwt-denylist", err)
|
||||
}
|
||||
if denied {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
if c.Use != "" && c.Use != tokenUseAccess {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
uid, err := strconv.ParseInt(c.Subject, 10, 64)
|
||||
if err != nil || uid <= 0 || c.DeviceID == 0 {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
exp := time.Time{}
|
||||
if c.ExpiresAt != nil {
|
||||
exp = c.ExpiresAt.Time
|
||||
}
|
||||
return &authentity.Principal{UserID: uid, DeviceID: c.DeviceID, JTI: c.ID, ExpiresAt: exp}, nil
|
||||
}
|
||||
|
||||
func (j *JWT) Revoke(ctx context.Context, token string) error {
|
||||
c, err := j.parse(token, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.ExpiresAt == nil {
|
||||
return nil
|
||||
}
|
||||
remain := c.ExpiresAt.Time.Sub(j.clock())
|
||||
if remain <= 0 {
|
||||
return nil
|
||||
}
|
||||
if err := j.denylist.Add(ctx, c.ID, remain); err != nil {
|
||||
return errs.ErrAvailable.WithError("jwt-denylist", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *JWT) parse(token string, allowExpired bool) (*claims, error) {
|
||||
if token == "" {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
opts := []jwt.ParserOption{
|
||||
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
|
||||
jwt.WithTimeFunc(j.clock),
|
||||
}
|
||||
if allowExpired {
|
||||
opts = append(opts, jwt.WithoutClaimsValidation())
|
||||
} else {
|
||||
opts = append(opts, jwt.WithIssuer(j.issuer), jwt.WithIssuedAt(), jwt.WithExpirationRequired())
|
||||
}
|
||||
parsed, err := jwt.ParseWithClaims(token, &claims{}, func(t *jwt.Token) (any, error) {
|
||||
if t.Method != jwt.SigningMethodHS256 {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return j.secret, nil
|
||||
}, opts...)
|
||||
if err != nil || parsed == nil || !parsed.Valid {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
c, ok := parsed.Claims.(*claims)
|
||||
if !ok || c.ID == "" {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
if allowExpired && c.Issuer != j.issuer {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
209
internal/module/auth/logic/jwt_test.go
Normal file
209
internal/module/auth/logic/jwt_test.go
Normal file
@ -0,0 +1,209 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
func testJWT(t *testing.T, clock func() time.Time) *JWT {
|
||||
t.Helper()
|
||||
j, err := NewJWT(&Options{
|
||||
Secret: "test-secret-test-secret",
|
||||
Issuer: "spark-im",
|
||||
TTL: time.Hour,
|
||||
Clock: clock,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
func TestJWTIssueAuthenticate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
j := testJWT(t, nil)
|
||||
tok, err := j.Issue(ctx, 42, 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok.UserID != 42 || tok.DeviceID != 7 || tok.ExpiresIn != 3600 || tok.AccessToken == "" || tok.RefreshToken == "" || tok.RefreshExpiresIn != 2592000 {
|
||||
t.Fatalf("%+v", tok)
|
||||
}
|
||||
p, err := j.Authenticate(ctx, tok.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.UserID != 42 || p.DeviceID != 7 || p.JTI == "" {
|
||||
t.Fatalf("%+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTRejectsBadToken(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
j := testJWT(t, nil)
|
||||
if _, err := j.Authenticate(ctx, ""); !errors.Is(err, errs.ErrUnauthorized) {
|
||||
t.Fatalf("empty: %v", err)
|
||||
}
|
||||
if _, err := j.Authenticate(ctx, "not-a-jwt"); !errors.Is(err, errs.ErrUnauthorized) {
|
||||
t.Fatalf("garbage: %v", err)
|
||||
}
|
||||
other, err := NewJWT(&Options{Secret: "other-secret-other-secret", Issuer: "spark-im"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tok, err := other.Issue(ctx, 1, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := j.Authenticate(ctx, tok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) {
|
||||
t.Fatalf("wrong secret: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTRejectsExpiredAndWrongIssuer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
clock := func() time.Time { return now }
|
||||
j := testJWT(t, clock)
|
||||
tok, err := j.Issue(ctx, 1, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j.clock = func() time.Time { return now.Add(2 * time.Hour) }
|
||||
if _, err := j.Authenticate(ctx, tok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) {
|
||||
t.Fatalf("expired: %v", err)
|
||||
}
|
||||
|
||||
wrongIss, err := NewJWT(&Options{Secret: "test-secret-test-secret", Issuer: "other", TTL: time.Hour, Clock: clock})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issTok, err := wrongIss.Issue(ctx, 1, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := j.Authenticate(ctx, issTok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) {
|
||||
t.Fatalf("issuer: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTRefreshRotatesAndRejectsOld(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
j := testJWT(t, nil)
|
||||
tok, err := j.Issue(ctx, 5, 8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := j.Authenticate(ctx, tok.RefreshToken); !errors.Is(err, errs.ErrUnauthorized) {
|
||||
t.Fatalf("refresh as access: %v", err)
|
||||
}
|
||||
next, err := j.Refresh(ctx, tok.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if next.AccessToken == tok.AccessToken || next.RefreshToken == tok.RefreshToken {
|
||||
t.Fatal("expected new pair")
|
||||
}
|
||||
if _, err := j.Authenticate(ctx, tok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) {
|
||||
t.Fatalf("old access: %v", err)
|
||||
}
|
||||
if _, err := j.Refresh(ctx, tok.RefreshToken); !errors.Is(err, errs.ErrUnauthorized) {
|
||||
t.Fatalf("old refresh: %v", err)
|
||||
}
|
||||
if _, err := j.Authenticate(ctx, next.AccessToken); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTLogoutRevokesPair(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
j := testJWT(t, nil)
|
||||
tok, err := j.Issue(ctx, 4, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := j.Authenticate(ctx, tok.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := j.Logout(ctx, p.UserID, p.DeviceID, p.JTI, p.ExpiresAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := j.Authenticate(ctx, tok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) {
|
||||
t.Fatalf("access after logout: %v", err)
|
||||
}
|
||||
if _, err := j.Refresh(ctx, tok.RefreshToken); !errors.Is(err, errs.ErrUnauthorized) {
|
||||
t.Fatalf("refresh after logout: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTDenylistRevoke(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
j := testJWT(t, nil)
|
||||
tok, err := j.Issue(ctx, 9, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := j.Revoke(ctx, tok.AccessToken); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := j.Authenticate(ctx, tok.AccessToken); !errors.Is(err, errs.ErrUnauthorized) {
|
||||
t.Fatalf("revoked: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTIssueRejectsBadIDs(t *testing.T) {
|
||||
j := testJWT(t, nil)
|
||||
if _, err := j.Issue(context.Background(), 0, 1); !errors.Is(err, errs.ErrInvalidArgument) {
|
||||
t.Fatalf("user: %v", err)
|
||||
}
|
||||
if _, err := j.Issue(context.Background(), 1, 0); !errors.Is(err, errs.ErrInvalidArgument) {
|
||||
t.Fatalf("device: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTRejectsNoneAlg(t *testing.T) {
|
||||
j := testJWT(t, nil)
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodNone, &claims{
|
||||
DeviceID: 1,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: "1",
|
||||
Issuer: "spark-im",
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
ID: "jti-none",
|
||||
},
|
||||
})
|
||||
raw, err := tok.SignedString(jwt.UnsafeAllowNoneSignatureType)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := j.Authenticate(context.Background(), raw); !errors.Is(err, errs.ErrUnauthorized) {
|
||||
t.Fatalf("none: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewJWTRequiresSecret(t *testing.T) {
|
||||
if _, err := NewJWT(&Options{}); err == nil {
|
||||
t.Fatal("expected secret error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryDenylistExpiry(t *testing.T) {
|
||||
d := NewMemoryDenylist()
|
||||
ctx := context.Background()
|
||||
if err := d.Add(ctx, "x", time.Millisecond); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
ok, err := d.Has(ctx, "x")
|
||||
if err != nil || ok {
|
||||
t.Fatalf("expired still present: %v %v", ok, err)
|
||||
}
|
||||
}
|
||||
151
internal/module/auth/logic/login.go
Normal file
151
internal/module/auth/logic/login.go
Normal file
@ -0,0 +1,151 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
authentity "spark-server/internal/module/auth/entity"
|
||||
userentity "spark-server/internal/module/user/entity"
|
||||
userlogic "spark-server/internal/module/user/logic"
|
||||
"spark-server/internal/pkg/errs"
|
||||
"spark-server/internal/pkg/i18n"
|
||||
"spark-server/internal/pkg/reqctx"
|
||||
"spark-server/internal/pkg/validate"
|
||||
)
|
||||
|
||||
// Verifier checks email OTP and records the login device. The user module owns the implementation.
|
||||
type Verifier interface {
|
||||
AuthenticateEmail(ctx context.Context, email, verifyCode string, device *userentity.LoginDevice) (*userentity.User, error)
|
||||
SendEmailCode(ctx context.Context, email string) error
|
||||
}
|
||||
|
||||
// LoginInput is the HTTP/TCP-shared login body. Validation tags drive client-visible messages.
|
||||
type LoginInput struct {
|
||||
Email string `json:"email" validate:"required,email" label:"field.email" errmsg:"required:validate.login.email_required;email:validate.login.email_invalid"`
|
||||
VerifyCode string `json:"verify_code" validate:"required" label:"field.verify_code" errmsg:"required:validate.login.code_required"`
|
||||
DeviceID uint64 `json:"device_id" validate:"required" label:"field.device_id" errmsg:"required:validate.login.device_required"`
|
||||
DeviceType string `json:"device_type" validate:"omitempty,oneof=ios android harmony windows macos web" label:"field.device_type" errmsg:"oneof:validate.login.device_type_invalid"`
|
||||
Platform string `json:"platform" validate:"omitempty,oneof=ios android harmony windows macos web" label:"field.device_type" errmsg:"oneof:validate.login.device_type_invalid"`
|
||||
DeviceName string `json:"device_name" validate:"omitempty,max=128" label:"field.device_name"`
|
||||
}
|
||||
|
||||
// SendCodeInput requests an email OTP. Test accounts do not need this.
|
||||
type SendCodeInput struct {
|
||||
Email string `json:"email" validate:"required,email" label:"field.email" errmsg:"required:validate.login.email_required;email:validate.login.email_invalid"`
|
||||
}
|
||||
|
||||
type SentCode struct {
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
|
||||
type RefreshInput struct {
|
||||
RefreshToken string `json:"refresh_token" validate:"required" label:"field.refresh_token" errmsg:"required:validate.login.refresh_required"`
|
||||
}
|
||||
|
||||
// SessionKicker drops the IM connection for one device after HTTP logout.
|
||||
type SessionKicker interface {
|
||||
KickDevice(userID int64, deviceID uint64)
|
||||
}
|
||||
|
||||
// LoginService is the application login use-case. Handlers only bind and call this.
|
||||
type LoginService struct {
|
||||
Issuer Issuer
|
||||
Users Verifier
|
||||
Sessions SessionKicker
|
||||
}
|
||||
|
||||
func NewLoginService(issuer Issuer, users Verifier) *LoginService {
|
||||
return &LoginService{Issuer: issuer, Users: users}
|
||||
}
|
||||
|
||||
func (s *LoginService) Login(ctx context.Context, in *LoginInput) (*authentity.IssuedToken, error) {
|
||||
if s == nil || s.Issuer == nil || s.Users == nil || in == nil {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !userentity.ValidDeviceType(loginDeviceType(in)) {
|
||||
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginDeviceTypeRequired, nil))
|
||||
}
|
||||
u, err := s.Users.AuthenticateEmail(ctx, in.Email, in.VerifyCode, loginDevice(ctx, in))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u == nil || u.ID <= 0 {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
tok, err := s.Issuer.Issue(ctx, u.ID, in.DeviceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tok.SparkID = u.SparkID
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
func (s *LoginService) SendEmailCode(ctx context.Context, in SendCodeInput) (*SentCode, error) {
|
||||
if s == nil || s.Users == nil {
|
||||
return nil, errs.ErrAvailable
|
||||
}
|
||||
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.Users.SendEmailCode(ctx, in.Email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SentCode{ExpiresIn: userlogic.EmailCodeTTLSeconds}, nil
|
||||
}
|
||||
|
||||
func (s *LoginService) Refresh(ctx context.Context, in RefreshInput) (*authentity.IssuedToken, error) {
|
||||
if s == nil || s.Issuer == nil {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Issuer.Refresh(ctx, in.RefreshToken)
|
||||
}
|
||||
|
||||
func (s *LoginService) Logout(ctx context.Context) error {
|
||||
if s == nil || s.Issuer == nil {
|
||||
return errs.ErrUnauthorized
|
||||
}
|
||||
rc := reqctx.From(ctx)
|
||||
if rc == nil || rc.UserID <= 0 || rc.DeviceID == 0 {
|
||||
return errs.ErrUnauthorized
|
||||
}
|
||||
if err := s.Issuer.Logout(ctx, rc.UserID, rc.DeviceID, rc.JTI, rc.TokenExpires); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Sessions != nil {
|
||||
s.Sessions.KickDevice(rc.UserID, rc.DeviceID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loginDeviceType(in *LoginInput) string {
|
||||
if in == nil {
|
||||
return ""
|
||||
}
|
||||
if t := userentity.NormalizeDeviceType(in.DeviceType); userentity.ValidDeviceType(t) {
|
||||
return t
|
||||
}
|
||||
return userentity.NormalizeDeviceType(in.Platform)
|
||||
}
|
||||
|
||||
func loginDevice(ctx context.Context, in *LoginInput) *userentity.LoginDevice {
|
||||
if in == nil {
|
||||
return &userentity.LoginDevice{}
|
||||
}
|
||||
d := &userentity.LoginDevice{
|
||||
DeviceID: in.DeviceID,
|
||||
DeviceType: loginDeviceType(in),
|
||||
Platform: in.Platform,
|
||||
DeviceName: in.DeviceName,
|
||||
RealIP: reqctx.RealIP(ctx),
|
||||
Language: reqctx.Language(ctx),
|
||||
}
|
||||
if rc := reqctx.From(ctx); rc != nil {
|
||||
d.AppVersion = rc.AppVersion
|
||||
}
|
||||
return d
|
||||
}
|
||||
92
internal/module/auth/logic/login_test.go
Normal file
92
internal/module/auth/logic/login_test.go
Normal file
@ -0,0 +1,92 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
userlogic "spark-server/internal/module/user/logic"
|
||||
usermodel "spark-server/internal/module/user/model"
|
||||
"spark-server/internal/pkg/errs"
|
||||
"spark-server/internal/pkg/reqctx"
|
||||
)
|
||||
|
||||
func TestLoginServiceValidatesAndIssues(t *testing.T) {
|
||||
j, err := NewJWT(&Options{Secret: "test-secret-test-secret", Issuer: "spark-im"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewLoginService(j, StaticUser{Email: "test01@spark.com", UserID: 11, SparkID: "test01"})
|
||||
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{RealIP: "203.0.113.10"})
|
||||
|
||||
_, err = svc.Login(ctx, &LoginInput{Email: "test01@spark.com"})
|
||||
app, ok := errs.AsAppError(err)
|
||||
if !ok || app.Code != errs.ErrInvalidArgument.Code || !strings.Contains(app.Msg, "请填写验证码") {
|
||||
t.Fatalf("validate %+v", err)
|
||||
}
|
||||
|
||||
enCtx := reqctx.With(t.Context(), &reqctx.RequestContext{Language: "en"})
|
||||
_, err = svc.Login(enCtx, &LoginInput{Email: "test01@spark.com"})
|
||||
app, ok = errs.AsAppError(err)
|
||||
if !ok || !strings.Contains(app.Msg, "Please enter the verification code") {
|
||||
t.Fatalf("en validate %+v", err)
|
||||
}
|
||||
|
||||
tok, err := svc.Login(ctx, &LoginInput{Email: "test01@spark.com", VerifyCode: "any", DeviceID: 7, DeviceType: "ios"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok.UserID != 11 || tok.DeviceID != 7 || tok.AccessToken == "" || tok.RefreshToken == "" || tok.SparkID != "test01" {
|
||||
t.Fatalf("%+v", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginServiceUnauthorized(t *testing.T) {
|
||||
j, err := NewJWT(&Options{Secret: "test-secret-test-secret"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := LoginInput{Email: "ada@example.com", VerifyCode: "000000", DeviceID: 1, DeviceType: "web"}
|
||||
svc := NewLoginService(j, StaticUser{Email: "ada@example.com", Code: "123456", UserID: 11})
|
||||
if _, err = svc.Login(t.Context(), &in); !errors.Is(err, errs.ErrUnauthorized) {
|
||||
t.Fatalf("bad code: %v", err)
|
||||
}
|
||||
in.VerifyCode = "123456"
|
||||
if _, err = NewLoginService(j, nil).Login(t.Context(), &in); !errors.Is(err, errs.ErrUnauthorized) {
|
||||
t.Fatalf("nil verifier: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginServiceRecordsContextDevice(t *testing.T) {
|
||||
j, err := NewJWT(&Options{Secret: "test-secret-test-secret", Issuer: "spark-im"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := usermodel.NewMemoryRepo()
|
||||
ids := seqIDs{n: 1000}
|
||||
users := userlogic.NewService(repo, usermodel.NewMemoryCodes(), &ids, nil)
|
||||
svc := NewLoginService(j, users)
|
||||
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{
|
||||
RealIP: "198.51.100.9", Language: "en", AppVersion: "2.1.0",
|
||||
})
|
||||
tok, err := svc.Login(ctx, &LoginInput{
|
||||
Email: "test07@spark.com", VerifyCode: "x", DeviceID: 9, Platform: "android", DeviceName: "Pixel",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dev, err := repo.GetDevice(ctx, tok.UserID, 9)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dev.DeviceType != "android" || dev.DeviceName != "Pixel" || dev.LastIP != "198.51.100.9" || dev.AppVersion != "2.1.0" || dev.Language != "en" {
|
||||
t.Fatalf("%+v", dev)
|
||||
}
|
||||
}
|
||||
|
||||
type seqIDs struct{ n int64 }
|
||||
|
||||
func (s *seqIDs) NextID() (int64, error) {
|
||||
s.n++
|
||||
return s.n, nil
|
||||
}
|
||||
135
internal/module/auth/logic/refresh.go
Normal file
135
internal/module/auth/logic/refresh.go
Normal file
@ -0,0 +1,135 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
const (
|
||||
tokenUseAccess = "access"
|
||||
tokenUseRefresh = "refresh"
|
||||
)
|
||||
|
||||
// TokenPair is the current access+refresh jti for one user device.
|
||||
type TokenPair struct {
|
||||
Access string `json:"a"`
|
||||
Refresh string `json:"r"`
|
||||
}
|
||||
|
||||
// PairStore holds the live token pair per device so refresh can rotate and logout can revoke both.
|
||||
type PairStore interface {
|
||||
Put(ctx context.Context, userID int64, deviceID uint64, pair TokenPair, ttl time.Duration) error
|
||||
Get(ctx context.Context, userID int64, deviceID uint64) (TokenPair, error)
|
||||
Delete(ctx context.Context, userID int64, deviceID uint64) error
|
||||
}
|
||||
|
||||
type MemoryPairs struct {
|
||||
mu sync.Mutex
|
||||
items map[string]storedPair
|
||||
}
|
||||
|
||||
type storedPair struct {
|
||||
pair TokenPair
|
||||
exp int64
|
||||
}
|
||||
|
||||
func NewMemoryPairs() *MemoryPairs {
|
||||
return &MemoryPairs{items: make(map[string]storedPair)}
|
||||
}
|
||||
|
||||
func pairKey(userID int64, deviceID uint64) string {
|
||||
return strconv.FormatInt(userID, 10) + ":" + strconv.FormatUint(deviceID, 10)
|
||||
}
|
||||
|
||||
func (s *MemoryPairs) Put(_ context.Context, userID int64, deviceID uint64, pair TokenPair, ttl time.Duration) error {
|
||||
if userID <= 0 || deviceID == 0 || ttl <= 0 {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.items[pairKey(userID, deviceID)] = storedPair{pair: pair, exp: time.Now().Add(ttl).UnixNano()}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemoryPairs) Get(_ context.Context, userID int64, deviceID uint64) (TokenPair, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
row, ok := s.items[pairKey(userID, deviceID)]
|
||||
if !ok {
|
||||
return TokenPair{}, nil
|
||||
}
|
||||
if time.Now().UnixNano() >= row.exp {
|
||||
delete(s.items, pairKey(userID, deviceID))
|
||||
return TokenPair{}, nil
|
||||
}
|
||||
return row.pair, nil
|
||||
}
|
||||
|
||||
func (s *MemoryPairs) Delete(_ context.Context, userID int64, deviceID uint64) error {
|
||||
s.mu.Lock()
|
||||
delete(s.items, pairKey(userID, deviceID))
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
type RedisPairs struct {
|
||||
rdb *redis.Client
|
||||
prefix string
|
||||
}
|
||||
|
||||
func NewRedisPairs(rdb *redis.Client, prefix string) *RedisPairs {
|
||||
if prefix == "" {
|
||||
prefix = "refresh:"
|
||||
}
|
||||
return &RedisPairs{rdb: rdb, prefix: prefix}
|
||||
}
|
||||
|
||||
func (s *RedisPairs) key(userID int64, deviceID uint64) string {
|
||||
return s.prefix + pairKey(userID, deviceID)
|
||||
}
|
||||
|
||||
func (s *RedisPairs) Put(ctx context.Context, userID int64, deviceID uint64, pair TokenPair, ttl time.Duration) error {
|
||||
if s == nil || s.rdb == nil {
|
||||
return errs.ErrAvailable.WithInnerMsg("jwt refresh redis not ready")
|
||||
}
|
||||
if userID <= 0 || deviceID == 0 || ttl <= 0 {
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(pair)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.rdb.Set(ctx, s.key(userID, deviceID), raw, ttl).Err()
|
||||
}
|
||||
|
||||
func (s *RedisPairs) Get(ctx context.Context, userID int64, deviceID uint64) (TokenPair, error) {
|
||||
if s == nil || s.rdb == nil {
|
||||
return TokenPair{}, errs.ErrAvailable.WithInnerMsg("jwt refresh redis not ready")
|
||||
}
|
||||
raw, err := s.rdb.Get(ctx, s.key(userID, deviceID)).Bytes()
|
||||
if err == redis.Nil {
|
||||
return TokenPair{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return TokenPair{}, err
|
||||
}
|
||||
var pair TokenPair
|
||||
if err := json.Unmarshal(raw, &pair); err != nil {
|
||||
return TokenPair{}, err
|
||||
}
|
||||
return pair, nil
|
||||
}
|
||||
|
||||
func (s *RedisPairs) Delete(ctx context.Context, userID int64, deviceID uint64) error {
|
||||
if s == nil || s.rdb == nil {
|
||||
return errs.ErrAvailable.WithInnerMsg("jwt refresh redis not ready")
|
||||
}
|
||||
return s.rdb.Del(ctx, s.key(userID, deviceID)).Err()
|
||||
}
|
||||
47
internal/module/auth/logic/static.go
Normal file
47
internal/module/auth/logic/static.go
Normal file
@ -0,0 +1,47 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
authentity "spark-server/internal/module/auth/entity"
|
||||
userentity "spark-server/internal/module/user/entity"
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
// Static accepts one opaque token. Tests and local bring-up only; production uses JWT.
|
||||
type Static struct {
|
||||
Token string
|
||||
UserID int64
|
||||
DeviceID uint64
|
||||
}
|
||||
|
||||
func (s Static) Authenticate(_ context.Context, token string) (*authentity.Principal, error) {
|
||||
if s.Token == "" || token != s.Token {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
return &authentity.Principal{UserID: s.UserID, DeviceID: s.DeviceID}, nil
|
||||
}
|
||||
|
||||
// StaticUser is a single email/OTP pair for tests that do not need the user module.
|
||||
type StaticUser struct {
|
||||
Email string
|
||||
Code string
|
||||
UserID int64
|
||||
SparkID string
|
||||
}
|
||||
|
||||
func (s StaticUser) AuthenticateEmail(_ context.Context, email, verifyCode string, _ *userentity.LoginDevice) (*userentity.User, error) {
|
||||
if s.Email == "" || userentity.NormalizeEmail(email) != userentity.NormalizeEmail(s.Email) {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
if s.Code != "" && verifyCode != s.Code {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
sparkID := s.SparkID
|
||||
if sparkID == "" {
|
||||
sparkID = "static"
|
||||
}
|
||||
return &userentity.User{ID: s.UserID, SparkID: sparkID, Status: userentity.StatusActive}, nil
|
||||
}
|
||||
|
||||
func (s StaticUser) SendEmailCode(context.Context, string) error { return nil }
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
15
internal/module/conversation/entity/conversation.go
Normal file
15
internal/module/conversation/entity/conversation.go
Normal file
@ -0,0 +1,15 @@
|
||||
// Package entity holds conversation domain types.
|
||||
package entity
|
||||
|
||||
const (
|
||||
TypeP2P = 1
|
||||
TypeGroup = 2
|
||||
)
|
||||
|
||||
// Conversation is a P2P or group chat. LastMsgID is the Snowflake cursor, not ConversationSeq.
|
||||
type Conversation struct {
|
||||
ID int64
|
||||
Type int
|
||||
LastMsgID int64
|
||||
LastMsgAt int64
|
||||
}
|
||||
10
internal/module/conversation/entity/member.go
Normal file
10
internal/module/conversation/entity/member.go
Normal file
@ -0,0 +1,10 @@
|
||||
package entity
|
||||
|
||||
// Member is a row in conversation_members. LastReadMsgID is the durable read cursor.
|
||||
type Member struct {
|
||||
ConversationID int64
|
||||
UserID int64
|
||||
LastReadMsgID int64
|
||||
JoinedAtMS int64
|
||||
Muted bool
|
||||
}
|
||||
15
internal/module/conversation/entity/p2p.go
Normal file
15
internal/module/conversation/entity/p2p.go
Normal file
@ -0,0 +1,15 @@
|
||||
package entity
|
||||
|
||||
// P2P is the unique pair mapping. UserSmall is min(a,b), UserBig is max(a,b).
|
||||
type P2P struct {
|
||||
UserSmall int64
|
||||
UserBig int64
|
||||
ConversationID int64
|
||||
}
|
||||
|
||||
func OrderUsers(a, b int64) (small, big int64) {
|
||||
if a <= b {
|
||||
return a, b
|
||||
}
|
||||
return b, a
|
||||
}
|
||||
17
internal/module/conversation/logic/conversation.go
Normal file
17
internal/module/conversation/logic/conversation.go
Normal file
@ -0,0 +1,17 @@
|
||||
// Package logic is the conversation use-case layer.
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"spark-server/internal/module/conversation/entity"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, c *entity.Conversation) error
|
||||
Get(ctx context.Context, id int64) (*entity.Conversation, error)
|
||||
GetP2P(ctx context.Context, userA, userB int64) (*entity.Conversation, error)
|
||||
UpdateLastMsg(ctx context.Context, id, msgID, atMS int64) error
|
||||
GetMember(ctx context.Context, conversationID, userID int64) (*entity.Member, error)
|
||||
UpdateLastRead(ctx context.Context, conversationID, userID, msgID int64) error
|
||||
}
|
||||
72
internal/module/conversation/logic/p2p.go
Normal file
72
internal/module/conversation/logic/p2p.go
Normal file
@ -0,0 +1,72 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"spark-server/internal/module/conversation/entity"
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
type IDGen interface {
|
||||
NextID() (int64, error)
|
||||
}
|
||||
|
||||
type P2PRepository interface {
|
||||
GetP2P(ctx context.Context, userA, userB int64) (*entity.Conversation, error)
|
||||
CreateP2P(ctx context.Context, c *entity.Conversation, p2p *entity.P2P, members []*entity.Member) error
|
||||
UpdateLastMsg(ctx context.Context, id, msgID, atMS int64) error
|
||||
}
|
||||
|
||||
// P2PService opens the unique 1:1 conversation for a user pair.
|
||||
type P2PService struct {
|
||||
repo P2PRepository
|
||||
ids IDGen
|
||||
}
|
||||
|
||||
func NewP2PService(repo P2PRepository, ids IDGen) *P2PService {
|
||||
return &P2PService{repo: repo, ids: ids}
|
||||
}
|
||||
|
||||
func (s *P2PService) EnsureP2P(ctx context.Context, userA, userB int64) (int64, error) {
|
||||
if s == nil || s.repo == nil || s.ids == nil || userA <= 0 || userB <= 0 || userA == userB {
|
||||
return 0, errs.ErrInvalidArgument
|
||||
}
|
||||
if c, err := s.repo.GetP2P(ctx, userA, userB); err == nil {
|
||||
return c.ID, nil
|
||||
} else if !errors.Is(err, errs.ErrNotFound) {
|
||||
return 0, err
|
||||
}
|
||||
id, err := s.ids.NextID()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
now := time.Now()
|
||||
small, big := entity.OrderUsers(userA, userB)
|
||||
conv := &entity.Conversation{ID: id, Type: entity.TypeP2P, LastMsgAt: now.UnixMilli()}
|
||||
p2p := &entity.P2P{UserSmall: small, UserBig: big, ConversationID: id}
|
||||
joined := now.UnixMilli()
|
||||
members := []*entity.Member{
|
||||
{ConversationID: id, UserID: userA, JoinedAtMS: joined},
|
||||
{ConversationID: id, UserID: userB, JoinedAtMS: joined},
|
||||
}
|
||||
if err := s.repo.CreateP2P(ctx, conv, p2p, members); err != nil {
|
||||
if errors.Is(err, errs.ErrDuplicate) {
|
||||
c, err := s.repo.GetP2P(ctx, userA, userB)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return c.ID, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *P2PService) UpdateLastMsg(ctx context.Context, conversationID, msgID, atMS int64) error {
|
||||
if s == nil || s.repo == nil {
|
||||
return errs.ErrAvailable
|
||||
}
|
||||
return s.repo.UpdateLastMsg(ctx, conversationID, msgID, atMS)
|
||||
}
|
||||
15
internal/module/conversation/model/conversation.go
Normal file
15
internal/module/conversation/model/conversation.go
Normal file
@ -0,0 +1,15 @@
|
||||
// Package model is conversation persistence. One file per table.
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Conversation maps table conversations.
|
||||
type Conversation struct {
|
||||
ID int64 `gorm:"column:id;primaryKey;comment:Snowflake 会话 id,时间有序主键"`
|
||||
Type int `gorm:"column:type;comment:会话类型:1=单聊 2=群聊"`
|
||||
LastMsgID int64 `gorm:"column:last_msg_id;comment:最新消息 Snowflake id,0 表示尚无消息"`
|
||||
LastMsgAt time.Time `gorm:"column:last_msg_at;comment:最新消息时间(毫秒精度)"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;comment:会话创建时间"`
|
||||
}
|
||||
|
||||
func (Conversation) TableName() string { return "conversations" }
|
||||
47
internal/module/conversation/model/conversation_member.go
Normal file
47
internal/module/conversation/model/conversation_member.go
Normal file
@ -0,0 +1,47 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"spark-server/internal/module/conversation/entity"
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
// ConversationMember maps table conversation_members.
|
||||
type ConversationMember struct {
|
||||
ConversationID int64 `gorm:"column:conversation_id;primaryKey;comment:会话 id"`
|
||||
UserID int64 `gorm:"column:user_id;primaryKey;comment:成员 user_id"`
|
||||
LastReadMsgID int64 `gorm:"column:last_read_msg_id;comment:该成员已读到的最大消息 id,未读真相;Redis 丢了可从此重建"`
|
||||
JoinedAt time.Time `gorm:"column:joined_at;comment:加入会话时间"`
|
||||
Muted int `gorm:"column:muted;comment:0=未静音 1=免打扰"`
|
||||
}
|
||||
|
||||
func (ConversationMember) TableName() string { return "conversation_members" }
|
||||
|
||||
func (r *ConversationMember) entity() *entity.Member {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
m := &entity.Member{
|
||||
ConversationID: r.ConversationID, UserID: r.UserID,
|
||||
LastReadMsgID: r.LastReadMsgID, JoinedAtMS: r.JoinedAt.UnixMilli(),
|
||||
}
|
||||
m.Muted = r.Muted != 0
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Repo) GetMember(ctx context.Context, conversationID, userID int64) (*entity.Member, error) {
|
||||
if conversationID <= 0 || userID <= 0 {
|
||||
return nil, errs.ErrInvalidArgument
|
||||
}
|
||||
db, err := m.session(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var row ConversationMember
|
||||
if err := db.Where("conversation_id = ? AND user_id = ?", conversationID, userID).Take(&row).Error; err != nil {
|
||||
return nil, mapDBErr(err)
|
||||
}
|
||||
return row.entity(), nil
|
||||
}
|
||||
102
internal/module/conversation/model/memory.go
Normal file
102
internal/module/conversation/model/memory.go
Normal file
@ -0,0 +1,102 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"spark-server/internal/module/conversation/entity"
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
type p2pKey struct {
|
||||
Small int64
|
||||
Big int64
|
||||
}
|
||||
|
||||
// MemoryP2P is an in-process conversation store for tests and empty-DSN bring-up.
|
||||
type MemoryP2P struct {
|
||||
mu sync.Mutex
|
||||
byID map[int64]*entity.Conversation
|
||||
byP2P map[p2pKey]int64
|
||||
members map[int64]map[int64]*entity.Member
|
||||
}
|
||||
|
||||
func NewMemoryP2P() *MemoryP2P {
|
||||
return &MemoryP2P{
|
||||
byID: make(map[int64]*entity.Conversation),
|
||||
byP2P: make(map[p2pKey]int64),
|
||||
members: make(map[int64]map[int64]*entity.Member),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MemoryP2P) GetP2P(_ context.Context, userA, userB int64) (*entity.Conversation, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
small, big := entity.OrderUsers(userA, userB)
|
||||
id, ok := m.byP2P[p2pKey{Small: small, Big: big}]
|
||||
if !ok {
|
||||
return nil, errs.ErrNotFound
|
||||
}
|
||||
cp := *m.byID[id]
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *MemoryP2P) CreateP2P(_ context.Context, c *entity.Conversation, p2p *entity.P2P, members []*entity.Member) error {
|
||||
if c == nil || p2p == nil {
|
||||
return errs.ErrInvalidArgument
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
key := p2pKey{Small: p2p.UserSmall, Big: p2p.UserBig}
|
||||
if _, ok := m.byP2P[key]; ok {
|
||||
return errs.ErrDuplicate
|
||||
}
|
||||
if _, ok := m.byID[c.ID]; ok {
|
||||
return errs.ErrDuplicate
|
||||
}
|
||||
cp := *c
|
||||
m.byID[c.ID] = &cp
|
||||
m.byP2P[key] = c.ID
|
||||
byUser := make(map[int64]*entity.Member, len(members))
|
||||
for _, mem := range members {
|
||||
if mem == nil {
|
||||
continue
|
||||
}
|
||||
row := *mem
|
||||
byUser[mem.UserID] = &row
|
||||
}
|
||||
m.members[c.ID] = byUser
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryP2P) GetMember(_ context.Context, conversationID, userID int64) (*entity.Member, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
byUser, ok := m.members[conversationID]
|
||||
if !ok {
|
||||
return nil, errs.ErrNotFound
|
||||
}
|
||||
mem, ok := byUser[userID]
|
||||
if !ok {
|
||||
return nil, errs.ErrNotFound
|
||||
}
|
||||
cp := *mem
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *MemoryP2P) UpdateLastMsg(_ context.Context, id, msgID, atMS int64) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c, ok := m.byID[id]
|
||||
if !ok {
|
||||
return errs.ErrNotFound
|
||||
}
|
||||
c.LastMsgID = msgID
|
||||
if atMS > 0 {
|
||||
c.LastMsgAt = atMS
|
||||
} else {
|
||||
c.LastMsgAt = time.Now().UnixMilli()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
10
internal/module/conversation/model/p2p_conversation.go
Normal file
10
internal/module/conversation/model/p2p_conversation.go
Normal file
@ -0,0 +1,10 @@
|
||||
package model
|
||||
|
||||
// P2PConversation maps table p2p_conversations.
|
||||
type P2PConversation struct {
|
||||
UserSmall int64 `gorm:"column:user_small;primaryKey;comment:两人中较小的 user_id,保证同一对用户只有一行"`
|
||||
UserBig int64 `gorm:"column:user_big;primaryKey;comment:两人中较大的 user_id"`
|
||||
ConversationID int64 `gorm:"column:conversation_id;comment:对应 conversations.id"`
|
||||
}
|
||||
|
||||
func (P2PConversation) TableName() string { return "p2p_conversations" }
|
||||
138
internal/module/conversation/model/repo.go
Normal file
138
internal/module/conversation/model/repo.go
Normal file
@ -0,0 +1,138 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"spark-server/internal/module/conversation/entity"
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
// Repo is MySQL persistence for conversations / p2p / members.
|
||||
type Repo struct {
|
||||
gdb *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepo(gdb *gorm.DB) *Repo {
|
||||
return &Repo{gdb: gdb}
|
||||
}
|
||||
|
||||
func (m *Repo) session(ctx context.Context) (*gorm.DB, error) {
|
||||
if m == nil || m.gdb == nil {
|
||||
return nil, errs.ErrAvailable
|
||||
}
|
||||
return m.gdb.WithContext(ctx), nil
|
||||
}
|
||||
|
||||
func mapDBErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errs.ErrNotFound
|
||||
}
|
||||
var me *mysqldriver.MySQLError
|
||||
if errors.As(err, &me) && me.Number == 1062 {
|
||||
return errs.ErrDuplicate
|
||||
}
|
||||
return errs.ErrInner.WithError("conversation-mysql", err)
|
||||
}
|
||||
|
||||
func (m *Repo) Get(ctx context.Context, id int64) (*entity.Conversation, error) {
|
||||
db, err := m.session(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var row Conversation
|
||||
if err := db.Where("id = ?", id).Take(&row).Error; err != nil {
|
||||
return nil, mapDBErr(err)
|
||||
}
|
||||
return row.entity(), nil
|
||||
}
|
||||
|
||||
func (m *Repo) GetP2P(ctx context.Context, userA, userB int64) (*entity.Conversation, error) {
|
||||
db, err := m.session(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
small, big := entity.OrderUsers(userA, userB)
|
||||
var row P2PConversation
|
||||
if err := db.Where("user_small = ? AND user_big = ?", small, big).Take(&row).Error; err != nil {
|
||||
return nil, mapDBErr(err)
|
||||
}
|
||||
return m.Get(ctx, row.ConversationID)
|
||||
}
|
||||
|
||||
func (m *Repo) CreateP2P(ctx context.Context, c *entity.Conversation, p2p *entity.P2P, members []*entity.Member) error {
|
||||
if c == nil || p2p == nil {
|
||||
return errs.ErrInvalidArgument
|
||||
}
|
||||
db, err := m.session(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
conv := Conversation{
|
||||
ID: c.ID, Type: c.Type, LastMsgID: c.LastMsgID,
|
||||
LastMsgAt: msTime(c.LastMsgAt), CreatedAt: msTime(c.LastMsgAt),
|
||||
}
|
||||
if err := tx.Create(&conv).Error; err != nil {
|
||||
return mapDBErr(err)
|
||||
}
|
||||
link := P2PConversation{UserSmall: p2p.UserSmall, UserBig: p2p.UserBig, ConversationID: p2p.ConversationID}
|
||||
if err := tx.Create(&link).Error; err != nil {
|
||||
return mapDBErr(err)
|
||||
}
|
||||
for _, mem := range members {
|
||||
row := ConversationMember{
|
||||
ConversationID: mem.ConversationID, UserID: mem.UserID,
|
||||
LastReadMsgID: mem.LastReadMsgID, JoinedAt: msTime(mem.JoinedAtMS),
|
||||
}
|
||||
if mem.Muted {
|
||||
row.Muted = 1
|
||||
}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return mapDBErr(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Repo) UpdateLastMsg(ctx context.Context, id, msgID, atMS int64) error {
|
||||
db, err := m.session(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res := db.Model(&Conversation{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"last_msg_id": msgID,
|
||||
"last_msg_at": msTime(atMS),
|
||||
})
|
||||
if res.Error != nil {
|
||||
return mapDBErr(res.Error)
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errs.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Conversation) entity() *entity.Conversation {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return &entity.Conversation{
|
||||
ID: r.ID, Type: r.Type, LastMsgID: r.LastMsgID, LastMsgAt: r.LastMsgAt.UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
func msTime(ms int64) time.Time {
|
||||
if ms <= 0 {
|
||||
return time.Now()
|
||||
}
|
||||
return time.UnixMilli(ms)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
30
internal/module/deliver/entity/job.go
Normal file
30
internal/module/deliver/entity/job.go
Normal file
@ -0,0 +1,30 @@
|
||||
// Package entity holds deliver-queue job types.
|
||||
package entity
|
||||
|
||||
// Job is a fanout unit. ExcludeDevice is the sender device. NotMailbox matches BFF NotSaveIch.
|
||||
type Job struct {
|
||||
MsgID int64 `json:"msg_id"`
|
||||
Cmd uint16 `json:"cmd"`
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
FromUserID int64 `json:"from_user_id"`
|
||||
FromDeviceID uint64 `json:"from_device_id"`
|
||||
ExcludeDevice uint64 `json:"exclude_device"`
|
||||
ToUserIDs []int64 `json:"to_user_ids,omitempty"`
|
||||
NotPush bool `json:"not_push,omitempty"`
|
||||
NotMailbox bool `json:"not_mailbox,omitempty"`
|
||||
Payload []byte `json:"payload,omitempty"`
|
||||
raw []byte `json:"-"`
|
||||
}
|
||||
|
||||
func (j *Job) Raw() []byte {
|
||||
if j == nil {
|
||||
return nil
|
||||
}
|
||||
return j.raw
|
||||
}
|
||||
|
||||
func (j *Job) SetRaw(raw []byte) {
|
||||
if j != nil {
|
||||
j.raw = raw
|
||||
}
|
||||
}
|
||||
8
internal/module/deliver/logic/done.go
Normal file
8
internal/module/deliver/logic/done.go
Normal file
@ -0,0 +1,8 @@
|
||||
package logic
|
||||
|
||||
import "context"
|
||||
|
||||
// Deduper is SETNX deliver:done:{msgId} so a retried job cannot increment unread twice.
|
||||
type Deduper interface {
|
||||
Mark(ctx context.Context, msgID int64) (first bool, err error)
|
||||
}
|
||||
15
internal/module/deliver/logic/queue.go
Normal file
15
internal/module/deliver/logic/queue.go
Normal file
@ -0,0 +1,15 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"spark-server/internal/module/deliver/entity"
|
||||
)
|
||||
|
||||
type Queue interface {
|
||||
Push(ctx context.Context, job *entity.Job) error
|
||||
Take(ctx context.Context, timeout time.Duration) (*entity.Job, error)
|
||||
Ack(ctx context.Context, job *entity.Job) error
|
||||
RequeueStale(ctx context.Context) error
|
||||
}
|
||||
161
internal/module/deliver/logic/worker.go
Normal file
161
internal/module/deliver/logic/worker.go
Normal file
@ -0,0 +1,161 @@
|
||||
// Package logic is the deliver worker and queue contracts.
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"spark-server/internal/infrastructure/logger"
|
||||
"spark-server/internal/module/deliver/entity"
|
||||
"spark-server/internal/transport/tcp/protocol"
|
||||
"spark-server/internal/transport/tcp/session"
|
||||
)
|
||||
|
||||
// Handler fans a job out. Return error to leave the job in processing for RequeueStale.
|
||||
type Handler func(ctx context.Context, job *entity.Job) error
|
||||
|
||||
type Worker struct {
|
||||
log *zap.Logger
|
||||
queue Queue
|
||||
done Deduper
|
||||
handle Handler
|
||||
n int
|
||||
timeout time.Duration
|
||||
stop chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
type WorkerOptions struct {
|
||||
Workers int
|
||||
TakeTimeout time.Duration
|
||||
Handler Handler
|
||||
}
|
||||
|
||||
func NewWorker(log *zap.Logger, q Queue, done Deduper, o WorkerOptions) *Worker {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
if o.Workers < 1 {
|
||||
o.Workers = 1
|
||||
}
|
||||
if o.TakeTimeout <= 0 {
|
||||
o.TakeTimeout = 5 * time.Second
|
||||
}
|
||||
if done == nil {
|
||||
done = NopDeduper{}
|
||||
}
|
||||
if o.Handler == nil {
|
||||
o.Handler = NopHandler
|
||||
}
|
||||
return &Worker{
|
||||
log: log, queue: q, done: done, handle: o.Handler,
|
||||
n: o.Workers, timeout: o.TakeTimeout, stop: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func NopHandler(context.Context, *entity.Job) error { return nil }
|
||||
|
||||
type NopDeduper struct{}
|
||||
|
||||
func (NopDeduper) Mark(context.Context, int64) (bool, error) { return true, nil }
|
||||
|
||||
func SessionFanout(sessions session.Manager) Handler {
|
||||
return func(ctx context.Context, job *entity.Job) error {
|
||||
if sessions == nil || job == nil || len(job.ToUserIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
cmd := job.Cmd
|
||||
if cmd == 0 {
|
||||
cmd = protocol.CmdMessage
|
||||
}
|
||||
pkt := &session.Packet{Command: cmd, Body: job.Payload}
|
||||
for _, uid := range job.ToUserIDs {
|
||||
_ = sessions.SendToUserExceptDevice(ctx, uid, job.ExcludeDevice, pkt)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) Start(ctx context.Context) {
|
||||
if w == nil || w.queue == nil {
|
||||
return
|
||||
}
|
||||
if err := w.queue.RequeueStale(ctx); err != nil {
|
||||
w.log.Error("deliver requeue", zap.Error(err))
|
||||
}
|
||||
for i := 0; i < w.n; i++ {
|
||||
w.wg.Add(1)
|
||||
go w.loop()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) Stop() {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-w.stop:
|
||||
default:
|
||||
close(w.stop)
|
||||
}
|
||||
w.wg.Wait()
|
||||
}
|
||||
|
||||
func (w *Worker) loop() {
|
||||
defer w.wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-w.stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), w.timeout+time.Second)
|
||||
job, err := w.queue.Take(ctx, w.timeout)
|
||||
cancel()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-w.stop:
|
||||
return
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
continue
|
||||
}
|
||||
if job == nil {
|
||||
select {
|
||||
case <-w.stop:
|
||||
return
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
w.process(job)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) process(job *entity.Job) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
w.log.Error("deliver panic", zap.Any("panic", rec), zap.Int64("msg_id", job.MsgID))
|
||||
}
|
||||
}()
|
||||
ctx := logger.Attach(context.Background(), w.log)
|
||||
first, err := w.done.Mark(ctx, job.MsgID)
|
||||
if err != nil {
|
||||
w.log.Error("deliver done", zap.Error(err), zap.Int64("msg_id", job.MsgID))
|
||||
return
|
||||
}
|
||||
if !first {
|
||||
_ = w.queue.Ack(ctx, job)
|
||||
return
|
||||
}
|
||||
if err := w.handle(ctx, job); err != nil {
|
||||
w.log.Error("deliver handle", zap.Error(err), zap.Int64("msg_id", job.MsgID))
|
||||
return
|
||||
}
|
||||
if err := w.queue.Ack(ctx, job); err != nil {
|
||||
w.log.Error("deliver ack", zap.Error(err), zap.Int64("msg_id", job.MsgID))
|
||||
}
|
||||
}
|
||||
110
internal/module/deliver/logic/worker_test.go
Normal file
110
internal/module/deliver/logic/worker_test.go
Normal file
@ -0,0 +1,110 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"spark-server/internal/module/deliver/entity"
|
||||
"spark-server/internal/module/deliver/model"
|
||||
)
|
||||
|
||||
func TestMemoryQueueAckUsesOriginalBytes(t *testing.T) {
|
||||
q := model.NewMemoryQueue()
|
||||
ctx := t.Context()
|
||||
job := &entity.Job{MsgID: 9, FromUserID: 1, ExcludeDevice: 7, ToUserIDs: []int64{2}}
|
||||
if err := q.Push(ctx, job); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := q.Take(ctx, time.Second)
|
||||
if err != nil || got == nil || got.MsgID != 9 {
|
||||
t.Fatalf("take %+v %v", got, err)
|
||||
}
|
||||
got.FromUserID = 99
|
||||
if err := q.Ack(ctx, got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := q.RequeueStale(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := q.Take(ctx, 10*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again != nil {
|
||||
t.Fatalf("acked job still in processing %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeduperSkipsSecond(t *testing.T) {
|
||||
d := model.NewMemoryDone()
|
||||
first, err := d.Mark(t.Context(), 11)
|
||||
if err != nil || !first {
|
||||
t.Fatalf("first %v %v", first, err)
|
||||
}
|
||||
second, err := d.Mark(t.Context(), 11)
|
||||
if err != nil || second {
|
||||
t.Fatalf("second %v %v", second, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerIdempotentAck(t *testing.T) {
|
||||
q := model.NewMemoryQueue()
|
||||
d := model.NewMemoryDone()
|
||||
var n atomic.Int32
|
||||
w := NewWorker(nil, q, d, WorkerOptions{
|
||||
Workers: 1, TakeTimeout: 50 * time.Millisecond,
|
||||
Handler: func(context.Context, *entity.Job) error { n.Add(1); return nil },
|
||||
})
|
||||
ctx := t.Context()
|
||||
raw, _ := json.Marshal(&entity.Job{MsgID: 3, Cmd: 6})
|
||||
if err := q.Push(ctx, &entity.Job{MsgID: 3}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = raw
|
||||
w.Start(ctx)
|
||||
defer w.Stop()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) && n.Load() == 0 {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if n.Load() != 1 {
|
||||
t.Fatalf("handled %d", n.Load())
|
||||
}
|
||||
if err := q.Push(ctx, &entity.Job{MsgID: 3}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
if n.Load() != 1 {
|
||||
t.Fatalf("idempotent %d", n.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerRecoversPanic(t *testing.T) {
|
||||
q := model.NewMemoryQueue()
|
||||
var n atomic.Int32
|
||||
w := NewWorker(nil, q, model.NewMemoryDone(), WorkerOptions{
|
||||
Workers: 1, TakeTimeout: 50 * time.Millisecond,
|
||||
Handler: func(_ context.Context, job *entity.Job) error {
|
||||
n.Add(1)
|
||||
if job.MsgID == 1 {
|
||||
panic("boom")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
ctx := t.Context()
|
||||
_ = q.Push(ctx, &entity.Job{MsgID: 1})
|
||||
_ = q.Push(ctx, &entity.Job{MsgID: 2})
|
||||
w.Start(ctx)
|
||||
defer w.Stop()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) && n.Load() < 2 {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if n.Load() < 2 {
|
||||
t.Fatalf("worker died after panic, handled %d", n.Load())
|
||||
}
|
||||
}
|
||||
117
internal/module/deliver/model/memory.go
Normal file
117
internal/module/deliver/model/memory.go
Normal file
@ -0,0 +1,117 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"spark-server/internal/module/deliver/entity"
|
||||
)
|
||||
|
||||
type MemoryQueue struct {
|
||||
mu sync.Mutex
|
||||
queue [][]byte
|
||||
processing [][]byte
|
||||
wait chan struct{}
|
||||
}
|
||||
|
||||
func NewMemoryQueue() *MemoryQueue {
|
||||
return &MemoryQueue{wait: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
func (q *MemoryQueue) Push(_ context.Context, job *entity.Job) error {
|
||||
raw, err := json.Marshal(job)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q.mu.Lock()
|
||||
q.queue = append(q.queue, raw)
|
||||
q.mu.Unlock()
|
||||
select {
|
||||
case q.wait <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *MemoryQueue) Take(ctx context.Context, timeout time.Duration) (*entity.Job, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
q.mu.Lock()
|
||||
if len(q.queue) > 0 {
|
||||
raw := q.queue[0]
|
||||
q.queue = q.queue[1:]
|
||||
q.processing = append(q.processing, raw)
|
||||
q.mu.Unlock()
|
||||
job := &entity.Job{}
|
||||
if err := json.Unmarshal(raw, job); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
job.SetRaw(raw)
|
||||
return job, nil
|
||||
}
|
||||
q.mu.Unlock()
|
||||
remain := time.Until(deadline)
|
||||
if remain <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(remain):
|
||||
return nil, nil
|
||||
case <-q.wait:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (q *MemoryQueue) Ack(_ context.Context, job *entity.Job) error {
|
||||
if job == nil {
|
||||
return nil
|
||||
}
|
||||
raw := job.Raw()
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
for i, p := range q.processing {
|
||||
if bytes.Equal(p, raw) {
|
||||
q.processing = append(q.processing[:i], q.processing[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *MemoryQueue) RequeueStale(context.Context) error {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if len(q.processing) == 0 {
|
||||
return nil
|
||||
}
|
||||
q.queue = append(q.processing, q.queue...)
|
||||
q.processing = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
type MemoryDone struct {
|
||||
mu sync.Mutex
|
||||
seen map[int64]struct{}
|
||||
}
|
||||
|
||||
func NewMemoryDone() *MemoryDone {
|
||||
return &MemoryDone{seen: make(map[int64]struct{})}
|
||||
}
|
||||
|
||||
func (d *MemoryDone) Mark(_ context.Context, msgID int64) (bool, error) {
|
||||
if msgID <= 0 {
|
||||
return true, nil
|
||||
}
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if _, ok := d.seen[msgID]; ok {
|
||||
return false, nil
|
||||
}
|
||||
d.seen[msgID] = struct{}{}
|
||||
return true, nil
|
||||
}
|
||||
122
internal/module/deliver/model/redis_queue.go
Normal file
122
internal/module/deliver/model/redis_queue.go
Normal file
@ -0,0 +1,122 @@
|
||||
// Package model is deliver queue persistence (Redis lists and SETNX done keys).
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"spark-server/internal/module/deliver/entity"
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
type RedisQueue struct {
|
||||
rdb *redis.Client
|
||||
queueKey string
|
||||
processKey string
|
||||
}
|
||||
|
||||
func NewRedisQueue(rdb *redis.Client, queueKey, processKey string) *RedisQueue {
|
||||
if queueKey == "" {
|
||||
queueKey = "deliver:queue"
|
||||
}
|
||||
if processKey == "" {
|
||||
processKey = "deliver:processing"
|
||||
}
|
||||
return &RedisQueue{rdb: rdb, queueKey: queueKey, processKey: processKey}
|
||||
}
|
||||
|
||||
func (q *RedisQueue) Push(ctx context.Context, job *entity.Job) error {
|
||||
if q.rdb == nil {
|
||||
return errs.ErrInternal
|
||||
}
|
||||
raw, err := json.Marshal(job)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return q.rdb.RPush(ctx, q.queueKey, raw).Err()
|
||||
}
|
||||
|
||||
func (q *RedisQueue) Take(ctx context.Context, timeout time.Duration) (*entity.Job, error) {
|
||||
if q.rdb == nil {
|
||||
return nil, errs.ErrInternal
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
res, err := q.rdb.BRPopLPush(ctx, q.queueKey, q.processKey, timeout).Result()
|
||||
if err == redis.Nil {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw := []byte(res)
|
||||
job := &entity.Job{}
|
||||
if err := json.Unmarshal(raw, job); err != nil {
|
||||
_ = q.rdb.LRem(ctx, q.processKey, 1, res).Err()
|
||||
return nil, fmt.Errorf("deliver job: %w", err)
|
||||
}
|
||||
job.SetRaw(raw)
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (q *RedisQueue) Ack(ctx context.Context, job *entity.Job) error {
|
||||
if q.rdb == nil || job == nil {
|
||||
return nil
|
||||
}
|
||||
raw := job.Raw()
|
||||
if len(raw) == 0 {
|
||||
var err error
|
||||
raw, err = json.Marshal(job)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return q.rdb.LRem(ctx, q.processKey, 1, raw).Err()
|
||||
}
|
||||
|
||||
func (q *RedisQueue) RequeueStale(ctx context.Context) error {
|
||||
if q.rdb == nil {
|
||||
return nil
|
||||
}
|
||||
for {
|
||||
_, err := q.rdb.RPopLPush(ctx, q.processKey, q.queueKey).Result()
|
||||
if err == redis.Nil {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type RedisDone struct {
|
||||
rdb *redis.Client
|
||||
prefix string
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewRedisDone(rdb *redis.Client, prefix string, ttl time.Duration) *RedisDone {
|
||||
if prefix == "" {
|
||||
prefix = "deliver:done:"
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = 10 * time.Minute
|
||||
}
|
||||
return &RedisDone{rdb: rdb, prefix: prefix, ttl: ttl}
|
||||
}
|
||||
|
||||
func (d *RedisDone) Mark(ctx context.Context, msgID int64) (bool, error) {
|
||||
if d == nil || d.rdb == nil || msgID <= 0 {
|
||||
return true, nil
|
||||
}
|
||||
ok, err := d.rdb.SetNX(ctx, fmt.Sprintf("%s%d", d.prefix, msgID), "1", d.ttl).Result()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
28
internal/module/favorite/entity/favorite.go
Normal file
28
internal/module/favorite/entity/favorite.go
Normal file
@ -0,0 +1,28 @@
|
||||
// Package entity holds favorite domain types. Media keys in Content are perm/fav copies.
|
||||
package entity
|
||||
|
||||
const (
|
||||
ListDefault = 20
|
||||
ListMax = 100
|
||||
)
|
||||
|
||||
// Favorite is a user-owned snapshot of a message. Object keys are copied off chat/.
|
||||
type Favorite struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
MsgID int64
|
||||
ConversationID int64
|
||||
MessageType int16
|
||||
Content []byte
|
||||
CreatedAtMS int64
|
||||
}
|
||||
|
||||
func ClampLimit(limit int) int {
|
||||
if limit <= 0 {
|
||||
return ListDefault
|
||||
}
|
||||
if limit > ListMax {
|
||||
return ListMax
|
||||
}
|
||||
return limit
|
||||
}
|
||||
51
internal/module/favorite/logic/copy.go
Normal file
51
internal/module/favorite/logic/copy.go
Normal file
@ -0,0 +1,51 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
msgentity "spark-server/internal/module/message/entity"
|
||||
uploadentity "spark-server/internal/module/upload/entity"
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
func (s *Service) copyMedia(ctx context.Context, userID int64, payload *msgentity.Content) ([]string, error) {
|
||||
keys := payload.ObjectKeys()
|
||||
if len(keys) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if s.objects == nil {
|
||||
return nil, errs.ErrAvailable
|
||||
}
|
||||
day := time.Now().UTC().Format("20060102")
|
||||
mapping := make(map[string]string, len(keys))
|
||||
copied := make([]string, 0, len(keys))
|
||||
for _, src := range keys {
|
||||
if _, ok := mapping[src]; ok {
|
||||
continue
|
||||
}
|
||||
objectID, err := s.ids.NextID()
|
||||
if err != nil {
|
||||
s.deleteKeys(ctx, copied)
|
||||
return nil, err
|
||||
}
|
||||
dst := uploadentity.BuildFavKey(userID, objectID, day, uploadentity.ExtOfKey(src))
|
||||
if err := s.objects.Copy(ctx, src, dst); err != nil {
|
||||
s.deleteKeys(ctx, copied)
|
||||
return nil, err
|
||||
}
|
||||
mapping[src] = dst
|
||||
copied = append(copied, dst)
|
||||
}
|
||||
payload.ReplaceObjectKeys(mapping)
|
||||
return copied, nil
|
||||
}
|
||||
|
||||
func (s *Service) deleteKeys(ctx context.Context, keys []string) {
|
||||
if s.objects == nil {
|
||||
return
|
||||
}
|
||||
for _, key := range keys {
|
||||
_ = s.objects.Delete(ctx, key)
|
||||
}
|
||||
}
|
||||
117
internal/module/favorite/logic/create.go
Normal file
117
internal/module/favorite/logic/create.go
Normal file
@ -0,0 +1,117 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"spark-server/internal/module/favorite/entity"
|
||||
msgentity "spark-server/internal/module/message/entity"
|
||||
"spark-server/internal/pkg/errs"
|
||||
"spark-server/internal/pkg/reqctx"
|
||||
"spark-server/internal/pkg/validate"
|
||||
)
|
||||
|
||||
// CreateInput favorites an existing message. Expired chat objects fail; no client re-upload.
|
||||
type CreateInput struct {
|
||||
MsgID int64 `json:"msg_id" validate:"required,gt=0" label:"field.msg_id" errmsg:"required:validate.favorite.msg_required;gt:validate.favorite.msg_invalid"`
|
||||
}
|
||||
|
||||
// Item is the HTTP/TCP favorite view. Content is the snapshot JSON (perm/fav keys).
|
||||
type Item struct {
|
||||
ID int64 `json:"id"`
|
||||
MsgID int64 `json:"msg_id"`
|
||||
ConversationID int64 `json:"conversation_id"`
|
||||
MessageType int16 `json:"message_type"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, userID int64, in *CreateInput) (*Item, error) {
|
||||
if !s.ready() || userID <= 0 {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
if in == nil {
|
||||
return nil, errs.ErrInvalidArgument
|
||||
}
|
||||
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing, err := s.repo.GetByUserMsg(ctx, userID, in.MsgID); err == nil {
|
||||
return itemOf(existing), nil
|
||||
} else if !errors.Is(err, errs.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
msg, payload, err := s.loadFavoriteSource(ctx, userID, in.MsgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copied, err := s.copyMedia(ctx, userID, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fav, err := s.persistFavorite(ctx, userID, msg, payload)
|
||||
if err != nil {
|
||||
s.deleteKeys(ctx, copied)
|
||||
if errors.Is(err, errs.ErrDuplicate) {
|
||||
existing, getErr := s.repo.GetByUserMsg(ctx, userID, in.MsgID)
|
||||
if getErr == nil {
|
||||
return itemOf(existing), nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return itemOf(fav), nil
|
||||
}
|
||||
|
||||
func (s *Service) loadFavoriteSource(ctx context.Context, userID, msgID int64) (*msgentity.Message, *msgentity.Content, error) {
|
||||
msg, err := s.msgs.GetByID(ctx, msgID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if msg.Status == msgentity.StatusHidden {
|
||||
return nil, nil, errs.ErrNotFound
|
||||
}
|
||||
if _, err := s.members.GetMember(ctx, msg.ConversationID, userID); err != nil {
|
||||
if errors.Is(err, errs.ErrNotFound) {
|
||||
return nil, nil, errs.ErrForbidden
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
payload, err := msg.Payload()
|
||||
if err != nil || payload == nil {
|
||||
return nil, nil, errs.ErrInvalidArgument
|
||||
}
|
||||
return msg, payload, nil
|
||||
}
|
||||
|
||||
func (s *Service) persistFavorite(ctx context.Context, userID int64, msg *msgentity.Message, payload *msgentity.Content) (*entity.Favorite, error) {
|
||||
raw, err := payload.Marshal()
|
||||
if err != nil {
|
||||
return nil, errs.ErrInvalidArgument
|
||||
}
|
||||
id, err := s.ids.NextID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
fav := &entity.Favorite{
|
||||
ID: id, UserID: userID, MsgID: msg.ID, ConversationID: msg.ConversationID,
|
||||
MessageType: int16(msg.Type), Content: raw, CreatedAtMS: now.UnixMilli(),
|
||||
}
|
||||
if err := s.repo.Create(ctx, fav); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fav, nil
|
||||
}
|
||||
|
||||
func itemOf(fav *entity.Favorite) *Item {
|
||||
if fav == nil {
|
||||
return nil
|
||||
}
|
||||
return &Item{
|
||||
ID: fav.ID, MsgID: fav.MsgID, ConversationID: fav.ConversationID,
|
||||
MessageType: fav.MessageType, Content: fav.Content, CreatedAt: fav.CreatedAtMS,
|
||||
}
|
||||
}
|
||||
136
internal/module/favorite/logic/favorite_test.go
Normal file
136
internal/module/favorite/logic/favorite_test.go
Normal file
@ -0,0 +1,136 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"spark-server/internal/infrastructure/id"
|
||||
convlogic "spark-server/internal/module/conversation/logic"
|
||||
convmodel "spark-server/internal/module/conversation/model"
|
||||
favmodel "spark-server/internal/module/favorite/model"
|
||||
msgentity "spark-server/internal/module/message/entity"
|
||||
msgmodel "spark-server/internal/module/message/model"
|
||||
"spark-server/internal/pkg/errs"
|
||||
"spark-server/internal/pkg/reqctx"
|
||||
)
|
||||
|
||||
type memObjects struct {
|
||||
keys map[string]bool
|
||||
}
|
||||
|
||||
func (m *memObjects) Copy(_ context.Context, src, dst string) error {
|
||||
if m.keys == nil || !m.keys[src] {
|
||||
return errs.ErrMediaExpired
|
||||
}
|
||||
m.keys[dst] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memObjects) Delete(_ context.Context, key string) error {
|
||||
delete(m.keys, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func testFav(t *testing.T) (*Service, *msgmodel.MemoryRepo, *convmodel.MemoryP2P, *memObjects, *id.Generator) {
|
||||
t.Helper()
|
||||
ids, err := id.New(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs := msgmodel.NewMemoryRepo()
|
||||
convs := convmodel.NewMemoryP2P()
|
||||
objs := &memObjects{keys: map[string]bool{}}
|
||||
svc := NewService(favmodel.NewMemoryRepo(), msgs, convs, objs, ids)
|
||||
return svc, msgs, convs, objs, ids
|
||||
}
|
||||
|
||||
func TestFavoriteCopiesMediaAndExpires(t *testing.T) {
|
||||
svc, msgs, convs, objs, ids := testFav(t)
|
||||
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{UserID: 1, Language: "zh-CN"})
|
||||
p2p := convlogic.NewP2PService(convs, ids)
|
||||
convID, err := p2p.EnsureP2P(ctx, 1, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := "chat/20260917/1/99.jpg"
|
||||
objs.keys[src] = true
|
||||
raw, err := msgentity.NewImage(&msgentity.Image{Key: src, Mime: "image/jpeg"}).Marshal()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgID, err := ids.NextID()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := msgs.Create(ctx, &msgentity.Message{
|
||||
ID: msgID, ConversationID: convID, SenderID: 1, ClientMsgID: "c1",
|
||||
Type: msgentity.TypeImage, Content: raw, Status: msgentity.StatusNormal,
|
||||
CreatedAtMS: time.Now().UnixMilli(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item, err := svc.Create(ctx, 1, &CreateInput{MsgID: msgID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.MsgID != msgID || !strings.Contains(string(item.Content), "perm/fav/1/") {
|
||||
t.Fatalf("%+v", item)
|
||||
}
|
||||
if strings.Contains(string(item.Content), src) {
|
||||
t.Fatal("still points at chat key")
|
||||
}
|
||||
again, err := svc.Create(ctx, 1, &CreateInput{MsgID: msgID})
|
||||
if err != nil || again.ID != item.ID {
|
||||
t.Fatalf("idempotent %+v %v", again, err)
|
||||
}
|
||||
|
||||
expiredID, err := ids.NextID()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gone := "chat/20260917/1/gone.jpg"
|
||||
raw2, err := msgentity.NewImage(&msgentity.Image{Key: gone, Mime: "image/jpeg"}).Marshal()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := msgs.Create(ctx, &msgentity.Message{
|
||||
ID: expiredID, ConversationID: convID, SenderID: 1, ClientMsgID: "c2",
|
||||
Type: msgentity.TypeImage, Content: raw2, Status: msgentity.StatusNormal,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = svc.Create(ctx, 1, &CreateInput{MsgID: expiredID})
|
||||
if !errors.Is(err, errs.ErrMediaExpired) {
|
||||
t.Fatalf("expired %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavoriteForbidsNonMember(t *testing.T) {
|
||||
svc, msgs, convs, _, ids := testFav(t)
|
||||
ctx := t.Context()
|
||||
p2p := convlogic.NewP2PService(convs, ids)
|
||||
convID, err := p2p.EnsureP2P(ctx, 1, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := msgentity.NewText("hi").Marshal()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgID, err := ids.NextID()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := msgs.Create(ctx, &msgentity.Message{
|
||||
ID: msgID, ConversationID: convID, SenderID: 1, ClientMsgID: "t1",
|
||||
Type: msgentity.TypeText, Content: raw, Status: msgentity.StatusNormal,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Create(ctx, 9, &CreateInput{MsgID: msgID}); !errors.Is(err, errs.ErrForbidden) {
|
||||
t.Fatalf("member %v", err)
|
||||
}
|
||||
}
|
||||
57
internal/module/favorite/logic/list.go
Normal file
57
internal/module/favorite/logic/list.go
Normal file
@ -0,0 +1,57 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"spark-server/internal/module/favorite/entity"
|
||||
msgentity "spark-server/internal/module/message/entity"
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
type ListResult struct {
|
||||
Items []*Item `json:"items"`
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, userID int64, limit int) (*ListResult, error) {
|
||||
if !s.ready() || userID <= 0 {
|
||||
return nil, errs.ErrUnauthorized
|
||||
}
|
||||
rows, err := s.repo.ListByUser(ctx, userID, entity.ClampLimit(limit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := &ListResult{Items: make([]*Item, 0, len(rows))}
|
||||
for _, row := range rows {
|
||||
out.Items = append(out.Items, itemOf(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, userID, id int64) error {
|
||||
if !s.ready() || userID <= 0 {
|
||||
return errs.ErrUnauthorized
|
||||
}
|
||||
if id <= 0 {
|
||||
return errs.ErrInvalidArgument
|
||||
}
|
||||
fav, err := s.repo.Get(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fav.UserID != userID {
|
||||
return errs.ErrForbidden
|
||||
}
|
||||
if err := s.repo.Delete(ctx, userID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.deleteFavoriteMedia(ctx, fav.Content)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) deleteFavoriteMedia(ctx context.Context, raw []byte) {
|
||||
payload, err := msgentity.UnmarshalContent(raw)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.deleteKeys(ctx, payload.ObjectKeys())
|
||||
}
|
||||
52
internal/module/favorite/logic/service.go
Normal file
52
internal/module/favorite/logic/service.go
Normal file
@ -0,0 +1,52 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
conventity "spark-server/internal/module/conversation/entity"
|
||||
"spark-server/internal/module/favorite/entity"
|
||||
msgentity "spark-server/internal/module/message/entity"
|
||||
)
|
||||
|
||||
// Repository persists favorites. Model implements it.
|
||||
type Repository interface {
|
||||
Get(ctx context.Context, id int64) (*entity.Favorite, error)
|
||||
GetByUserMsg(ctx context.Context, userID, msgID int64) (*entity.Favorite, error)
|
||||
Create(ctx context.Context, fav *entity.Favorite) error
|
||||
ListByUser(ctx context.Context, userID int64, limit int) ([]*entity.Favorite, error)
|
||||
Delete(ctx context.Context, userID, id int64) error
|
||||
}
|
||||
|
||||
type Messages interface {
|
||||
GetByID(ctx context.Context, id int64) (*msgentity.Message, error)
|
||||
}
|
||||
|
||||
type Members interface {
|
||||
GetMember(ctx context.Context, conversationID, userID int64) (*conventity.Member, error)
|
||||
}
|
||||
|
||||
type Objects interface {
|
||||
Copy(ctx context.Context, src, dst string) error
|
||||
Delete(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
type IDGen interface {
|
||||
NextID() (int64, error)
|
||||
}
|
||||
|
||||
// Service is the favorite use-case layer (HTTP and TCP share it).
|
||||
type Service struct {
|
||||
repo Repository
|
||||
msgs Messages
|
||||
members Members
|
||||
objects Objects
|
||||
ids IDGen
|
||||
}
|
||||
|
||||
func NewService(repo Repository, msgs Messages, members Members, objects Objects, ids IDGen) *Service {
|
||||
return &Service{repo: repo, msgs: msgs, members: members, objects: objects, ids: ids}
|
||||
}
|
||||
|
||||
func (s *Service) ready() bool {
|
||||
return s != nil && s.repo != nil && s.msgs != nil && s.members != nil && s.ids != nil
|
||||
}
|
||||
44
internal/module/favorite/model/favorite.go
Normal file
44
internal/module/favorite/model/favorite.go
Normal file
@ -0,0 +1,44 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"spark-server/internal/module/favorite/entity"
|
||||
)
|
||||
|
||||
// Favorite maps table favorites.
|
||||
type Favorite struct {
|
||||
ID int64 `gorm:"column:id;primaryKey;comment:Snowflake 收藏 id"`
|
||||
UserID int64 `gorm:"column:user_id;comment:收藏者 user_id"`
|
||||
MsgID int64 `gorm:"column:msg_id;comment:原消息 Snowflake id,仅作来源;撤回不影响收藏"`
|
||||
ConversationID int64 `gorm:"column:conversation_id;comment:原会话 id,仅作来源"`
|
||||
MessageType int16 `gorm:"column:message_type;comment:消息类型,与 messages.message_type 一致"`
|
||||
Content []byte `gorm:"column:content;type:blob;comment:收藏快照(JSON 字节);媒体 key 已复制到 perm/fav/"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;comment:收藏时间"`
|
||||
}
|
||||
|
||||
func (Favorite) TableName() string { return "favorites" }
|
||||
|
||||
func (r *Favorite) Entity() *entity.Favorite {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return &entity.Favorite{
|
||||
ID: r.ID, UserID: r.UserID, MsgID: r.MsgID, ConversationID: r.ConversationID,
|
||||
MessageType: r.MessageType, Content: r.Content, CreatedAtMS: r.CreatedAt.UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
func fromEntity(f *entity.Favorite) *Favorite {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
row := &Favorite{
|
||||
ID: f.ID, UserID: f.UserID, MsgID: f.MsgID, ConversationID: f.ConversationID,
|
||||
MessageType: f.MessageType, Content: f.Content,
|
||||
}
|
||||
if f.CreatedAtMS > 0 {
|
||||
row.CreatedAt = time.UnixMilli(f.CreatedAtMS)
|
||||
}
|
||||
return row
|
||||
}
|
||||
104
internal/module/favorite/model/memory.go
Normal file
104
internal/module/favorite/model/memory.go
Normal file
@ -0,0 +1,104 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"spark-server/internal/module/favorite/entity"
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
type userMsgKey struct {
|
||||
UserID int64
|
||||
MsgID int64
|
||||
}
|
||||
|
||||
// MemoryRepo is an in-process favorite store for tests and empty-DSN bring-up.
|
||||
type MemoryRepo struct {
|
||||
mu sync.Mutex
|
||||
byID map[int64]*entity.Favorite
|
||||
byPair map[userMsgKey]int64
|
||||
}
|
||||
|
||||
func NewMemoryRepo() *MemoryRepo {
|
||||
return &MemoryRepo{byID: make(map[int64]*entity.Favorite), byPair: make(map[userMsgKey]int64)}
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) Get(_ context.Context, id int64) (*entity.Favorite, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
row, ok := m.byID[id]
|
||||
if !ok {
|
||||
return nil, errs.ErrNotFound
|
||||
}
|
||||
return cloneFav(row), nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) GetByUserMsg(_ context.Context, userID, msgID int64) (*entity.Favorite, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
id, ok := m.byPair[userMsgKey{UserID: userID, MsgID: msgID}]
|
||||
if !ok {
|
||||
return nil, errs.ErrNotFound
|
||||
}
|
||||
return cloneFav(m.byID[id]), nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) Create(_ context.Context, fav *entity.Favorite) error {
|
||||
if fav == nil || fav.ID <= 0 || fav.UserID <= 0 {
|
||||
return errs.ErrInvalidArgument
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
key := userMsgKey{UserID: fav.UserID, MsgID: fav.MsgID}
|
||||
if _, ok := m.byPair[key]; ok {
|
||||
return errs.ErrDuplicate
|
||||
}
|
||||
if _, ok := m.byID[fav.ID]; ok {
|
||||
return errs.ErrDuplicate
|
||||
}
|
||||
m.byID[fav.ID] = cloneFav(fav)
|
||||
m.byPair[key] = fav.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) ListByUser(_ context.Context, userID int64, limit int) ([]*entity.Favorite, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
limit = entity.ClampLimit(limit)
|
||||
out := make([]*entity.Favorite, 0)
|
||||
for _, row := range m.byID {
|
||||
if row.UserID == userID {
|
||||
out = append(out, cloneFav(row))
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *MemoryRepo) Delete(_ context.Context, userID, id int64) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
row, ok := m.byID[id]
|
||||
if !ok || row.UserID != userID {
|
||||
return errs.ErrNotFound
|
||||
}
|
||||
delete(m.byID, id)
|
||||
delete(m.byPair, userMsgKey{UserID: row.UserID, MsgID: row.MsgID})
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneFav(f *entity.Favorite) *entity.Favorite {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *f
|
||||
if f.Content != nil {
|
||||
cp.Content = append([]byte(nil), f.Content...)
|
||||
}
|
||||
return &cp
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user