From edec7d82e9c2a63eba79915589bdec7d4fdf52c3 Mon Sep 17 00:00:00 2001 From: zhr Date: Tue, 22 Sep 2026 17:07:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=201=E3=80=81square=202=E3=80=81smtp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/verify-before-done.mdc | 17 + .cursor/skills/spark-im-v3/SKILL.md | 12 +- .cursor/skills/spark-im-v3/http.md | 5 +- .cursor/skills/spark-im-v3/i18n.md | 6 +- AGENTS.md | 1 + Makefile | 5 +- README.md | 2 +- configs/config.yaml | 21 + deploy/docker/README.md | 68 +++ deploy/docker/config.docker.yaml | 21 + deploy/docker/deploy.sh | 96 +++-- deploy/docker/lib.sh | 160 +++++++ deploy/docker/rollback.sh | 26 ++ docs/square.md | 400 ++++++++++++++++++ internal/bootstrap/app.go | 98 +++-- internal/bootstrap/config.go | 47 ++ internal/bootstrap/health_test.go | 1 + internal/bootstrap/sms.go | 69 +++ internal/infrastructure/sms/aliyun.go | 183 ++++++++ internal/infrastructure/smtp/mailer.go | 140 ++++++ internal/module/auth/logic/login.go | 24 -- internal/module/auth/logic/login_test.go | 30 +- internal/module/auth/logic/static.go | 2 - internal/module/friend/logic/friend_test.go | 4 +- internal/module/group/logic/group_test.go | 4 +- internal/module/message/logic/message_test.go | 4 +- internal/module/meta/entity/enum.go | 2 + internal/module/meta/logic/enums.go | 2 + internal/module/meta/logic/enums_sms.go | 22 + internal/module/meta/logic/enums_wire.go | 1 + internal/module/moment/logic/moment_test.go | 4 +- internal/module/sms/entity/carrier.go | 15 + internal/module/sms/entity/channel.go | 18 + internal/module/sms/entity/phone.go | 54 +++ internal/module/sms/entity/purpose.go | 18 + internal/module/sms/entity/target.go | 31 ++ internal/module/sms/entity/target_test.go | 17 + internal/module/sms/logic/provider.go | 18 + internal/module/sms/logic/send.go | 178 ++++++++ internal/module/sms/logic/send_more_test.go | 110 +++++ internal/module/sms/logic/send_test.go | 24 ++ internal/module/sms/logic/service.go | 69 +++ internal/module/sms/logic/store.go | 11 + internal/module/sms/logic/verify.go | 65 +++ internal/module/sms/logic/verify_test.go | 34 ++ internal/module/sms/model/memory_store.go | 58 +++ internal/module/sms/model/redis_store.go | 69 +++ internal/module/square/entity/content.go | 157 +++++++ internal/module/square/entity/kind.go | 83 ++++ internal/module/square/entity/post.go | 71 ++++ internal/module/square/logic/album.go | 57 +++ internal/module/square/logic/attach.go | 155 +++++++ internal/module/square/logic/comment.go | 117 +++++ internal/module/square/logic/delete.go | 36 ++ internal/module/square/logic/feed.go | 57 +++ internal/module/square/logic/like.go | 59 +++ internal/module/square/logic/limit.go | 44 ++ internal/module/square/logic/notice.go | 124 ++++++ internal/module/square/logic/publish.go | 146 +++++++ internal/module/square/logic/purge.go | 22 + internal/module/square/logic/service.go | 75 ++++ internal/module/square/logic/square_test.go | 249 +++++++++++ internal/module/square/logic/view.go | 104 +++++ internal/module/square/logic/visible.go | 83 ++++ internal/module/square/model/memory.go | 224 ++++++++++ internal/module/square/model/memory_purge.go | 87 ++++ internal/module/square/model/memory_write.go | 156 +++++++ internal/module/square/model/post.go | 175 ++++++++ internal/module/square/model/purge.go | 102 +++++ internal/module/square/model/query.go | 186 ++++++++ internal/module/square/model/repo.go | 50 +++ internal/module/square/model/write.go | 225 ++++++++++ internal/module/upload/entity/purpose.go | 10 + internal/module/upload/logic/credential.go | 2 +- internal/module/user/logic/delete.go | 31 +- internal/module/user/logic/delete_test.go | 10 +- internal/module/user/logic/email_code.go | 67 --- internal/module/user/logic/email_code_test.go | 21 - internal/module/user/logic/login.go | 13 +- internal/module/user/logic/login_test.go | 18 +- internal/module/user/logic/profile_test.go | 2 +- internal/module/user/logic/service.go | 13 +- internal/module/user/logic/service_test.go | 9 +- internal/module/user/logictest/wire.go | 24 ++ internal/module/user/model/email_code.go | 68 --- .../module/user/model/email_code_memory.go | 57 --- internal/pkg/errs/codes.go | 1 + internal/pkg/i18n/catalog_err.go | 3 + internal/pkg/i18n/catalog_validate_sms.go | 61 +++ internal/pkg/i18n/catalog_validate_square.go | 65 +++ internal/pkg/i18n/i18n_test.go | 2 +- internal/pkg/i18n/keys.go | 29 ++ internal/pkg/i18n/locale.go | 18 +- internal/pkg/reply/reply_test.go | 2 +- internal/pkg/reqctx/reqctx_test.go | 2 +- internal/pkg/validate/validate.go | 2 +- internal/pkg/validate/validate_test.go | 4 +- internal/transport/http/auth.go | 4 +- internal/transport/http/docs/openapi.yaml | 273 +++++++++++- internal/transport/http/file_test.go | 5 +- internal/transport/http/friend_test.go | 5 +- internal/transport/http/group_test.go | 5 +- .../transport/http/handler/admin/users.go | 8 +- .../transport/http/handler/auth/handler.go | 14 - .../transport/http/handler/sms/handler.go | 63 +++ .../transport/http/handler/square/handler.go | 245 +++++++++++ .../transport/http/handler/user/handler.go | 9 +- internal/transport/http/inbox_test.go | 5 +- internal/transport/http/message_test.go | 5 +- internal/transport/http/meta_test.go | 17 +- internal/transport/http/moment_test.go | 5 +- internal/transport/http/router.go | 34 +- internal/transport/http/server.go | 32 +- internal/transport/http/server_test.go | 23 +- internal/transport/tcp/session/conn.go | 8 +- migrations/0018_square.sql | 66 +++ 116 files changed, 6003 insertions(+), 467 deletions(-) create mode 100644 .cursor/rules/verify-before-done.mdc create mode 100644 deploy/docker/README.md create mode 100755 deploy/docker/lib.sh create mode 100755 deploy/docker/rollback.sh create mode 100644 docs/square.md create mode 100644 internal/bootstrap/sms.go create mode 100644 internal/infrastructure/sms/aliyun.go create mode 100644 internal/infrastructure/smtp/mailer.go create mode 100644 internal/module/meta/logic/enums_sms.go create mode 100644 internal/module/sms/entity/carrier.go create mode 100644 internal/module/sms/entity/channel.go create mode 100644 internal/module/sms/entity/phone.go create mode 100644 internal/module/sms/entity/purpose.go create mode 100644 internal/module/sms/entity/target.go create mode 100644 internal/module/sms/entity/target_test.go create mode 100644 internal/module/sms/logic/provider.go create mode 100644 internal/module/sms/logic/send.go create mode 100644 internal/module/sms/logic/send_more_test.go create mode 100644 internal/module/sms/logic/send_test.go create mode 100644 internal/module/sms/logic/service.go create mode 100644 internal/module/sms/logic/store.go create mode 100644 internal/module/sms/logic/verify.go create mode 100644 internal/module/sms/logic/verify_test.go create mode 100644 internal/module/sms/model/memory_store.go create mode 100644 internal/module/sms/model/redis_store.go create mode 100644 internal/module/square/entity/content.go create mode 100644 internal/module/square/entity/kind.go create mode 100644 internal/module/square/entity/post.go create mode 100644 internal/module/square/logic/album.go create mode 100644 internal/module/square/logic/attach.go create mode 100644 internal/module/square/logic/comment.go create mode 100644 internal/module/square/logic/delete.go create mode 100644 internal/module/square/logic/feed.go create mode 100644 internal/module/square/logic/like.go create mode 100644 internal/module/square/logic/limit.go create mode 100644 internal/module/square/logic/notice.go create mode 100644 internal/module/square/logic/publish.go create mode 100644 internal/module/square/logic/purge.go create mode 100644 internal/module/square/logic/service.go create mode 100644 internal/module/square/logic/square_test.go create mode 100644 internal/module/square/logic/view.go create mode 100644 internal/module/square/logic/visible.go create mode 100644 internal/module/square/model/memory.go create mode 100644 internal/module/square/model/memory_purge.go create mode 100644 internal/module/square/model/memory_write.go create mode 100644 internal/module/square/model/post.go create mode 100644 internal/module/square/model/purge.go create mode 100644 internal/module/square/model/query.go create mode 100644 internal/module/square/model/repo.go create mode 100644 internal/module/square/model/write.go delete mode 100644 internal/module/user/logic/email_code.go delete mode 100644 internal/module/user/logic/email_code_test.go create mode 100644 internal/module/user/logictest/wire.go delete mode 100644 internal/module/user/model/email_code.go delete mode 100644 internal/module/user/model/email_code_memory.go create mode 100644 internal/pkg/i18n/catalog_validate_sms.go create mode 100644 internal/pkg/i18n/catalog_validate_square.go create mode 100644 internal/transport/http/handler/sms/handler.go create mode 100644 internal/transport/http/handler/square/handler.go create mode 100644 migrations/0018_square.sql diff --git a/.cursor/rules/verify-before-done.mdc b/.cursor/rules/verify-before-done.mdc new file mode 100644 index 0000000..39c7615 --- /dev/null +++ b/.cursor/rules/verify-before-done.mdc @@ -0,0 +1,17 @@ +--- +description: Run make lint and make test before finishing any code change +alwaysApply: true +--- + +# Verify before done + +After **any** change to Go code, configs, migrations, OpenAPI, or i18n catalogs in this repo: + +1. Run `make lint` from the repo root (`golangci-lint`; see `.golangci.yaml`). +2. Run `make test` from the repo root (full `go test ./...` plus logic-package coverage gate). + +**Do not** tell the user the task is complete, ready to merge, or OK to deploy until **both** commands exit 0. + +If either fails, fix the failures and re-run both. Do not skip hooks or substitute partial checks (e.g. only `go test` on one package) unless the user explicitly asked for a scoped check. + +Docs-only or comment-only edits with zero build/test surface may skip verification; anything that can affect compile, lint, or tests must run both targets. diff --git a/.cursor/skills/spark-im-v3/SKILL.md b/.cursor/skills/spark-im-v3/SKILL.md index 8cddbe3..201612e 100644 --- a/.cursor/skills/spark-im-v3/SKILL.md +++ b/.cursor/skills/spark-im-v3/SKILL.md @@ -107,7 +107,17 @@ AUTH/login rate-limit uses `reqctx.RealIP`. TaskPool workers recover panics. Del - 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`). Logout is `POST /v1/auth/logout` (current device). Account cancellation is `DELETE /v1/users/me` (archive to `user_delete`, purge friends/moments/related). - 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). + +## Before declaring done (required) + +After modifying this repo, run from the project root: + +```bash +make lint +make test +``` + +Both must exit 0. `make test` runs all packages and enforces logic-layer coverage (see root `Makefile`). Do not mark work complete or hand off to the user until lint and test pass. Fix failures and re-run. ## Additional resources diff --git a/.cursor/skills/spark-im-v3/http.md b/.cursor/skills/spark-im-v3/http.md index 3d88280..c11a7ea 100644 --- a/.cursor/skills/spark-im-v3/http.md +++ b/.cursor/skills/spark-im-v3/http.md @@ -79,7 +79,8 @@ Public HTTP is `/v1/{resource}/...`. Register with Gin groups in `internal/trans | Group | Prefix | Examples | |-------|--------|----------| | system | `/healthz`, `/readyz`, `/docs` | liveness, readiness, OpenAPI(`/docs` API,`/docs/enums` 枚举,`/docs/errors` 错误码) | -| auth | `/v1/auth` | `POST /v1/auth/login`, `POST /v1/auth/email-codes`, `POST /v1/auth/token/refresh`, `POST /v1/auth/logout` | +| auth | `/v1/auth` | `POST /v1/auth/login`, `POST /v1/auth/token/refresh`, `POST /v1/auth/logout` | +| sms | `/v1/sms` | `POST /v1/sms/codes`(`X-Language` 必填) | | users | `/v1/users` | `GET/PATCH/DELETE /v1/users/me` | | friends | `/v1/friends` | apply, requests, accept, profile, remark, pin, block, report;`GET /v1/friends/lookup?q=` | | groups | `/v1/groups` | create, profile, members, invite, kick, leave, transfer, dissolve | @@ -94,7 +95,7 @@ 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, `GET /v1/meta/enums`). +- Anonymous paths belong in `publicPath` (login, `POST /v1/sms/codes`, token refresh, healthz, readyz, docs, `GET /v1/meta/enums`). - OpenAPI `tags` must match these groups. Docs jump tags `enums` / `errors` sit above `system` and open `/docs/enums`、`/docs/errors`. ## Checklist when adding an HTTP API diff --git a/.cursor/skills/spark-im-v3/i18n.md b/.cursor/skills/spark-im-v3/i18n.md index dc6a169..5462b1f 100644 --- a/.cursor/skills/spark-im-v3/i18n.md +++ b/.cursor/skills/spark-im-v3/i18n.md @@ -4,14 +4,16 @@ User-visible **error `msg`** and **notification copy** are never hardcoded in ha ## 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)`. +Canonical tags: `zh-CN`, `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`. +Missing or unknown tags → **`en`** (default). `zh` / `zh-Hans` → `zh-CN`. `zh-TW` / `zh-HK` / `zh-Hant` → `zh-TW`. `en*` → `en`. + +`POST /v1/sms/codes` requires **`X-Language`** explicitly (`i18n.ParseExplicit`); Accept-Language alone is not enough. Do not put language in JWT. Rate limit still uses `reqctx.RealIP`, not language. diff --git a/AGENTS.md b/AGENTS.md index 259c598..2f0ef26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,5 +9,6 @@ Before writing code: 3. Keep functions within `.golangci.yaml` (`funlen` 100, `lll` 240) 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. +6. Before finishing any code change, run `make lint` and `make test` at the repo root; both must pass. Do not report the task as OK until they do. Do not introduce Kafka, K8s, microservices, ConversationSeq, application-layer handshake crypto, or public gnet ports. diff --git a/Makefile b/Makefile index b282c14..ac8be9f 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ LINT ?= golangci-lint CONFIG ?= configs/config.yaml LOGIC_COVER_MIN ?= 65 -.PHONY: all build image test lint fmt vet tidy migrate start run deploy +.PHONY: all build image test lint fmt vet tidy migrate start run deploy deploy-rollback all: lint test build @@ -68,3 +68,6 @@ run: build deploy: bash deploy/docker/deploy.sh + +deploy-rollback: + bash deploy/docker/rollback.sh diff --git a/README.md b/README.md index 739525c..378dea8 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ HTTP 与 TCP **应答 Body** 同一套信封。业务成败只看 `code` 字符 ## 登录 -1. (可选)`POST /v1/auth/email-codes` Body `{"email"}`。 +1. (可选)`POST /v1/sms/codes`,Header **`X-Language` 必填**,Body `{"purpose":1,"target":"…"}`。 2. `POST /v1/auth/login` 拿 `access_token` / `refresh_token`。`device_id` 由**客户端生成并持久化**,服务端不分配;写入 JWT `did`。 3. 其它 HTTP:`Authorization: Bearer …`,可选 `X-Device-ID`(须等于登录时的 device_id)。 4. 续期:`POST /v1/auth/token/refresh`,Body `{"refresh_token"}`,不要带 Bearer。 diff --git a/configs/config.yaml b/configs/config.yaml index c683bcc..38ec834 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -96,3 +96,24 @@ object_storage: use_ssl: false presign_ttl: 15m chat_expire_days: 365 + +sms: + smtp: + host: "smtpdm-ap-southeast-1.aliyuncs.com" + port: 465 + username: "noreply@sparkmail.wyrs365.com" + password: "gD9UNvSt5ilagfa1" + from: "Spark IM " + aliyun_sms: + access_key_id: "" + access_key_secret: "" + sign_name: "" + templates: + login: + mobile: "" + unicom: "" + telecom: "" + account_delete: + mobile: "" + unicom: "" + telecom: "" diff --git a/deploy/docker/README.md b/deploy/docker/README.md new file mode 100644 index 0000000..2bb742c --- /dev/null +++ b/deploy/docker/README.md @@ -0,0 +1,68 @@ +# ECS 部署与回滚 + +## 目录(主机 `/data/spark-server`) + +| 路径 | 说明 | +| --- | --- | +| `compose/docker-compose.yaml` | 服务定义 | +| `configs/config.yaml` | 运行配置(部署前会备份到 `configs/backup/`) | +| `images/spark-server-.tar.gz` | 每次发布的镜像包(保留历史) | +| `images/spark-server.tar.gz` | 最近一次上传的包(兼容旧流程) | +| `releases/manifest.json` | 当前/上一版镜像 ID、release、时间 | +| `releases/previous_image_id` | 发布前 `latest` 的镜像 ID | +| Docker 标签 `spark-server:rollback` | 发布前从 `latest` 打的回滚点 | + +## 发布 + +```bash +make deploy +# 或 +bash deploy/docker/deploy.sh +``` + +流程概要: + +1. 本地 `make image`,打标签 `spark-server:-<时间>` 与 `latest` +2. 上传镜像 tar、compose、config、nginx 片段 +3. **在 load 新镜像前**,远端把当前 `spark-server:latest` 标为 `spark-server:rollback`,并备份 config +4. `docker load` → `compose up --force-recreate` → **migrate** +5. 轮询 `healthz` + `readyz`;失败则 **自动回滚** 到 `:rollback`(不跑 migrate) +6. 写 `releases/manifest.json` + +环境变量:`SPARK_SSH_HOST`、`SPARK_SSH_KEY`、`SPARK_DATA_DIR`、`SPARK_RELEASE`(固定 release 名)、`SPARK_IMAGE_BASE`(默认 `spark-server`)、`SPARK_UPLOAD_CONFIG=1`(仅首次或刻意覆盖远端 `config.yaml`)。 + +生产密钥(SMTP、JWT 等)写在 ECS 上的 `/data/spark-server/configs/config.yaml`;部署默认不覆盖该文件。 + +## 回滚 + +**自动**:本次 `deploy.sh` 健康检查失败时会执行与手动回滚相同的步骤。 + +**手动**(上一版已成功跑过、且之后只做过一次 deploy): + +```bash +make deploy-rollback +# 或 +bash deploy/docker/rollback.sh +``` + +将 `spark-server:rollback` 重新标为 `latest` 并重建容器。**不执行 migrate**。 + +### 限制(必读) + +- **数据库迁移不可自动回滚**。回滚只恢复旧二进制;若新版本已执行 forward-only 迁移(如 `0018_square.sql`),旧进程通常仍能跑(新表闲置),但若迁移做了破坏性变更,需要 DBA 手工处理或从备份恢复库。 +- 若连续多次 deploy,`:rollback` 只保留**上一次** deploy 前的镜像。更早版本用 `images/spark-server-.tar.gz`: + + ```bash + ssh ... 'docker load -i /data/spark-server/images/spark-server-<旧release>.tar.gz && \ + docker tag spark-server:latest spark-server:latest && \ + cd /data/spark-server/compose && docker compose up -d --force-recreate spark-server' + ``` + +- 回滚 config:从 `configs/backup/config.yaml.<时间>` 拷回 `configs/config.yaml` 后再 `compose up`。 + +## 验证 + +```bash +curl -fsS http://127.0.0.1:18080/healthz # 在 ECS 上 +curl -fsS http://127.0.0.1:18080/readyz +``` diff --git a/deploy/docker/config.docker.yaml b/deploy/docker/config.docker.yaml index 92b59fc..672a567 100644 --- a/deploy/docker/config.docker.yaml +++ b/deploy/docker/config.docker.yaml @@ -96,3 +96,24 @@ object_storage: use_ssl: false presign_ttl: 15m chat_expire_days: 365 + +sms: + smtp: + host: "smtpdm-ap-southeast-1.aliyuncs.com" + port: 465 + username: "noreply@sparkmail.wyrs365.com" + password: "gD9UNvSt5ilagfa1" + from: "Spark IM " + aliyun_sms: + access_key_id: "" + access_key_secret: "" + sign_name: "" + templates: + login: + mobile: "" + unicom: "" + telecom: "" + account_delete: + mobile: "" + unicom: "" + telecom: "" diff --git a/deploy/docker/deploy.sh b/deploy/docker/deploy.sh index 1879497..361de5a 100755 --- a/deploy/docker/deploy.sh +++ b/deploy/docker/deploy.sh @@ -1,54 +1,66 @@ #!/usr/bin/env bash -# Build linux/amd64 binaries + runtime image locally, then load them on the ECS host. -# Host layout: -# /data/spark-server/compose docker-compose.yaml -# /data/spark-server/configs config.yaml -# /data/spark-server/logs process logs -# /data/spark-server/images spark-server.tar.gz +# Build linux/amd64 binaries + runtime image locally, deploy to ECS, with rollback on health failure. 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}" -DATA="${SPARK_DATA_DIR:-/data/spark-server}" -IMAGE="${SPARK_IMAGE:-spark-server:latest}" -TAR="${SPARK_IMAGE_TAR:-$ROOT/bin/spark-server.tar.gz}" -SSH=(ssh -i "$KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$HOST") -SCP=(scp -i "$KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new) +DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=lib.sh +source "$DIR/lib.sh" +deploy_lib_init -need() { - command -v "$1" >/dev/null || { - echo "need $1 on this machine" >&2 - exit 1 - } -} +ROOT="$ROOT" +RELEASE="$(deploy_lib_release "$ROOT")" +IMAGE_VERSIONED="${IMAGE_BASE}:${RELEASE}" +TAR_VERSIONED="$ROOT/bin/spark-server-${RELEASE}.tar.gz" +TAR_LATEST="$ROOT/bin/spark-server.tar.gz" -need go -need docker -need rsync -need ssh -need scp +deploy_lib_need_tools +echo "==> release $RELEASE" echo "==> local linux/amd64 binaries + image" -make -C "$ROOT" image -mkdir -p "$(dirname "$TAR")" -docker save "$IMAGE" | gzip -c >"$TAR" -echo "image $(docker image inspect "$IMAGE" --format '{{.Size}}') bytes, tar $(wc -c <"$TAR") bytes" +make -C "$ROOT" image IMAGE="$IMAGE_LATEST" +docker tag "$IMAGE_LATEST" "$IMAGE_VERSIONED" +mkdir -p "$(dirname "$TAR_VERSIONED")" +docker save "$IMAGE_VERSIONED" | gzip -c >"$TAR_VERSIONED" +cp -f "$TAR_VERSIONED" "$TAR_LATEST" +echo "image $(docker image inspect "$IMAGE_LATEST" --format '{{.Size}}') bytes, tar $(wc -c <"$TAR_VERSIONED") bytes" -echo "==> upload" -"${SSH[@]}" "mkdir -p $DATA/compose $DATA/configs $DATA/logs $DATA/images" -"${SCP[@]}" "$TAR" "$HOST:$DATA/images/spark-server.tar.gz" +echo "==> upload (config on host is never overwritten unless SPARK_UPLOAD_CONFIG=1)" +"${SSH[@]}" "mkdir -p $DATA/compose $DATA/configs $DATA/logs $DATA/images $DATA/releases" +echo "==> stamp rollback point on host (before image load)" +deploy_lib_remote_stamp_rollback "$DATA" +"${SCP[@]}" "$TAR_VERSIONED" "$HOST:$DATA/images/$(basename "$TAR_VERSIONED")" +"${SCP[@]}" "$TAR_LATEST" "$HOST:$DATA/images/spark-server.tar.gz" "${SCP[@]}" "$ROOT/deploy/docker/docker-compose.yaml" "$HOST:$DATA/compose/docker-compose.yaml" -"${SCP[@]}" "$ROOT/deploy/docker/config.docker.yaml" "$HOST:$DATA/configs/config.yaml" +if [[ "${SPARK_UPLOAD_CONFIG:-0}" == "1" ]]; then + echo "==> upload config (SPARK_UPLOAD_CONFIG=1)" + "${SCP[@]}" "$ROOT/deploy/docker/config.docker.yaml" "$HOST:$DATA/configs/config.yaml" +fi rsync -az -e "ssh -i $KEY -o BatchMode=yes" \ "$ROOT/deploy/nginx/host/" "$HOST:/tmp/spark-nginx-host/" -echo "==> load and start" -"${SSH[@]}" "chown -R 10001:10001 $DATA/logs && docker load -i $DATA/images/spark-server.tar.gz" -# Replace any stale spark-server container (e.g. old docker run) so compose can recreate. -"${SSH[@]}" "docker rm -f spark-server 2>/dev/null || true" -"${SSH[@]}" "cd $DATA/compose && docker compose -f docker-compose.yaml up -d --force-recreate spark-server" -"${SSH[@]}" "docker exec spark-server /usr/local/bin/spark-migrate -config /app/configs/config.yaml -dir /app/migrations" -"${SSH[@]}" "cp /tmp/spark-nginx-host/spark-sni.conf /etc/nginx/stream.d/spark-sni.conf && if [ -f /etc/letsencrypt/live/sparkr.wyrs365.com/fullchain.pem ]; then cp /tmp/spark-nginx-host/sparkr.conf /etc/nginx/conf.d/sparkr.conf; fi && nginx -t && systemctl reload nginx" -"${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" +echo "==> load, migrate, start" +deploy_lib_remote_load_and_up "$DATA" "$DATA/images/$(basename "$TAR_VERSIONED")" 1 "$RELEASE" + +echo "==> nginx reload" +deploy_lib_remote_nginx_reload + +echo "==> health check" +if deploy_lib_remote_health 12 5; then + deploy_lib_remote_write_manifest "$DATA" "$RELEASE" "$(basename "$TAR_VERSIONED")" "ok" + deploy_lib_remote_ps + echo "==> deploy OK ($RELEASE)" + "${SSH[@]}" "curl -fsS http://127.0.0.1:18080/healthz && echo && curl -fsS http://127.0.0.1:18080/readyz && echo" + exit 0 +fi + +echo "==> health check failed — rolling back app to spark-server:rollback" >&2 +if deploy_lib_remote_rollback "$DATA" && deploy_lib_remote_health 8 5; then + deploy_lib_remote_write_manifest "$DATA" "$RELEASE" "$(basename "$TAR_VERSIONED")" "failed_rolled_back" + deploy_lib_remote_ps + echo "rolled back to previous image; fix the build and redeploy" >&2 + exit 1 +fi + +echo "==> rollback also failed — check docker logs on host" >&2 +deploy_lib_remote_ps +exit 2 diff --git a/deploy/docker/lib.sh b/deploy/docker/lib.sh new file mode 100755 index 0000000..f4ba4ad --- /dev/null +++ b/deploy/docker/lib.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# Shared SSH paths and remote deploy/rollback helpers for ECS. +set -euo pipefail + +deploy_lib_root() { + local here + here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + cd "$here/../.." && pwd +} + +deploy_lib_init() { + ROOT="$(deploy_lib_root)" + HOST="${SPARK_SSH_HOST:-root@47.236.66.9}" + KEY="${SPARK_SSH_KEY:-$HOME/.ssh/spark-ecs}" + DATA="${SPARK_DATA_DIR:-/data/spark-server}" + IMAGE_BASE="${SPARK_IMAGE_BASE:-spark-server}" + IMAGE_LATEST="${IMAGE_BASE}:latest" + ROLLBACK_TAG="${IMAGE_BASE}:rollback" + SSH=(ssh -i "$KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$HOST") + SCP=(scp -i "$KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new) +} + +deploy_lib_release() { + local root="${1:?}" + if [[ -n "${SPARK_RELEASE:-}" ]]; then + printf '%s' "$SPARK_RELEASE" + return + fi + local sha + sha="$(git -C "$root" rev-parse --short HEAD 2>/dev/null || echo manual)" + printf '%s-%s' "$sha" "$(date +%Y%m%d%H%M%S)" +} + +deploy_lib_need_tools() { + local t + for t in go docker rsync ssh scp curl; do + command -v "$t" >/dev/null || { + echo "need $t on this machine" >&2 + exit 1 + } + done +} + +# Tag spark-server:latest as :rollback on the host before overwriting latest. +deploy_lib_remote_stamp_rollback() { + local data="${1:?}" + "${SSH[@]}" bash -s "$data" "$ROLLBACK_TAG" "$IMAGE_LATEST" <<'EOS' +set -euo pipefail +DATA="$1" +ROLLBACK_TAG="$2" +LATEST="$3" +mkdir -p "$DATA/releases" "$DATA/configs/backup" +if docker image inspect "$LATEST" >/dev/null 2>&1; then + docker tag "$LATEST" "$ROLLBACK_TAG" + docker image inspect "$LATEST" --format '{{.Id}}' >"$DATA/releases/previous_image_id" + date -Iseconds >"$DATA/releases/previous_stamped_at" +fi +if [[ -f "$DATA/configs/config.yaml" ]]; then + cp -a "$DATA/configs/config.yaml" "$DATA/configs/backup/config.yaml.$(date +%Y%m%d%H%M%S)" +fi +EOS +} + +deploy_lib_remote_load_and_up() { + local data="${1:?}" tar_path="${2:?}" run_migrate="${3:-1}" release_tag="${4:-}" + "${SSH[@]}" bash -s "$data" "$tar_path" "$IMAGE_LATEST" "$run_migrate" "$release_tag" "$IMAGE_BASE" <<'EOS' +set -euo pipefail +DATA="$1" +TAR="$2" +LATEST="$3" +RUN_MIGRATE="$4" +RELEASE_TAG="$5" +IMAGE_BASE="$6" +chown -R 10001:10001 "$DATA/logs" +docker load -i "$TAR" +if [[ -n "$RELEASE_TAG" ]]; then + docker tag "${IMAGE_BASE}:${RELEASE_TAG}" "$LATEST" +else + loaded="$(docker image ls --format '{{.Repository}}:{{.Tag}}' | grep "^${IMAGE_BASE}:" | grep -v rollback | grep -v latest | head -1)" + if [[ -n "$loaded" ]]; then + docker tag "$loaded" "$LATEST" + fi +fi +docker rm -f spark-server 2>/dev/null || true +cd "$DATA/compose" +docker compose -f docker-compose.yaml up -d --force-recreate spark-server +for i in $(seq 1 30); do + if docker exec spark-server wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1; then + break + fi + sleep 2 +done +if [[ "$RUN_MIGRATE" == "1" ]]; then + docker exec spark-server /usr/local/bin/spark-migrate -config /app/configs/config.yaml -dir /app/migrations +fi +EOS +} + +deploy_lib_remote_health() { + local attempts="${1:-12}" sleep_s="${2:-5}" + local i + for ((i = 1; i <= attempts; i++)); do + if "${SSH[@]}" "curl -fsS http://127.0.0.1:18080/healthz >/dev/null && curl -fsS http://127.0.0.1:18080/readyz >/dev/null"; then + return 0 + fi + sleep "$sleep_s" + done + return 1 +} + +deploy_lib_remote_rollback() { + local data="${1:?}" + "${SSH[@]}" bash -s "$data" "$ROLLBACK_TAG" "$IMAGE_LATEST" <<'EOS' +set -euo pipefail +DATA="$1" +ROLLBACK_TAG="$2" +LATEST="$3" +if ! docker image inspect "$ROLLBACK_TAG" >/dev/null 2>&1; then + echo "no rollback image ($ROLLBACK_TAG); load a previous tar from $DATA/images/ manually" >&2 + exit 1 +fi +docker tag "$ROLLBACK_TAG" "$LATEST" +docker rm -f spark-server 2>/dev/null || true +cd "$DATA/compose" +docker compose -f docker-compose.yaml up -d --force-recreate spark-server +EOS +} + +deploy_lib_remote_write_manifest() { + local data="${1:?}" release="${2:?}" tar_name="${3:?}" status="${4:?}" + "${SSH[@]}" bash -s "$data" "$release" "$tar_name" "$status" "$IMAGE_LATEST" <<'EOS' +set -euo pipefail +DATA="$1" +RELEASE="$2" +TAR_NAME="$3" +STATUS="$4" +LATEST="$5" +ID="$(docker image inspect "$LATEST" --format '{{.Id}}' 2>/dev/null || echo unknown)" +PREV_ID="" +[[ -f "$DATA/releases/previous_image_id" ]] && PREV_ID="$(cat "$DATA/releases/previous_image_id")" +cat >"$DATA/releases/manifest.json" < rollback on $HOST (app only, no migrate)" +if ! deploy_lib_remote_rollback "$DATA"; then + echo "rollback failed" >&2 + exit 1 +fi +if ! deploy_lib_remote_health 12 5; then + echo "rollback container unhealthy" >&2 + deploy_lib_remote_ps + exit 1 +fi +deploy_lib_remote_write_manifest "$DATA" "rollback" "(rollback-tag)" "rolled_back" +deploy_lib_remote_ps +echo "==> health OK after rollback" +"${SSH[@]}" "curl -fsS http://127.0.0.1:18080/healthz && echo && curl -fsS http://127.0.0.1:18080/readyz && echo" diff --git a/docs/square.md b/docs/square.md new file mode 100644 index 0000000..17b593d --- /dev/null +++ b/docs/square.md @@ -0,0 +1,400 @@ +# 广场设计 + +> 对照:公开广场流 + 可选「仅自己 / 部分好友」;发帖形态对齐微信朋友圈 +> 形态:Spark IM 模块化单体内的独立业务,不进会话、不走 `messages` +> 状态:**已实现**(`internal/module/square`、`GET/POST /v1/square/...`) +> 硬约束:遵守 `docs/architecture-v3.md` C1–C7。对照已落地朋友圈见 `docs/moments.md`。 + +广场是独立于朋友圈的**内容频道**:默认发帖经审核通过后进入**全站广场 Feed**;发帖可选 **仅自己**、**部分好友可看**(白名单)、**不给谁看**(黑名单,其余登录用户可看)。私密/白名单帖不进全站 Feed。与好友 **拉黑** 关系任一侧存在时,**默认双方互相看不到对方广场内容**(无需写进 `audience_uids`)。载体与朋友圈一致。分表、分 REST、前缀 `perm/square/`。 + +--- + +# 0. 需求摘要 + +| # | 需求 | 设计要点 | +| --- | --- | --- | +| 1 | 发送方式与朋友圈类似 | 同一套 **content JSON**;`square_image` / `square_video` | +| 2 | 默认审核通过;公开类帖进全站 Feed | `audit_status`;v1 即 `approved`;全站 Feed 拉 `visibility∈{公开,不给谁看}`,再按读者过滤 | +| 3 | 仅自己 / 部分好友 / **不给谁看** | `visibility` 0/1/2/3 + `square_audience`(2=白名单能看,3=黑名单不能看);名单须双向好友,最多 200 | +| 3b | **拉黑好友默认不给看** | 任意可见性下,`friend.BlockedEither(作者,读者)` → 不可见、不可赞评 | +| 4 | 广场列表分页 | `cursor` + `limit`,**默认 20**、上限 50(与朋友圈一致,**不改**朋友圈默认) | +| 6 | **v1 就要赞/评通知** | `square_notices` + 列表/已读/未读角标 API(对齐朋友圈,不进会话 inbox) | + +--- + +# 0.1 做 / 不做 + +## 做(v1) + +| 能力 | 说明 | +| --- | --- | +| 发帖 | 公开 / 仅自己 / 部分好友 / **不给谁看(黑名单)** | +| 全站广场 Feed | `visibility∈{0,3}` + 审核通过 + 未删;**读时**剔除拉黑对与黑名单命中 | +| 作者主页流 | `GET /v1/square/users/{uid}/posts`:按可见性对当前浏览者过滤 | +| 单帖详情 | 有权读者可读(见 §3.5) | +| 点赞 / 取消赞 | 幂等;写 `square_notices`(赞) | +| 评论 / 回复 / 删评 | 全量展示评论(无共同好友过滤);写通知(评/回复) | +| 互动通知 | 列表、已读、未读红点(**不进** `messages` / mailbox) | +| 作者删帖 | 软删 + 删对象 | +| 审核字段 | 默认通过 | + +## 不做(v1) + +| 能力 | 原因 | +| --- | --- | +| 朋友圈式「不看他 / 可见天数」 | 属 `moment_*`(广场无 hide 边) | +| 广场帖写进 `messages` / 会话 inbox | C1/C7 | +| 全站 TCP 推送每条新帖 | 非聊天;赞评可选二期 `deliver` Push | +| 推荐算法、话题、@ | 后续 | +| 未登录刷广场 | 需 Bearer | + +--- + +# 0.2 与朋友圈的差异 + +| 维度 | 朋友圈 | 广场 | +| --- | --- | --- | +| 主 Feed | 好友时间线 | **全站流**(`visibility` 公开 + 不给谁看,读时过滤) | +| 可见性默认 | 好友公开 | **全站公开**(审核通过后) | +| 不给谁看 | 黑名单 visibility=3 | 同枚举;仍进 Feed 拉取,名单内不可见 | +| 拉黑 | Feed 过滤 | **默认**双方互不可见(无需写 audience) | +| 私密/白名单 | 不进好友 Feed | 不进全站 Feed;主页流按权限 | +| 评论规则 | 共同好友 | **全部展示** | +| 通知 | `moment_notices` | `square_notices`(v1 同等能力) | +| 表 / API | `moment_*`、`/v1/moments` | `square_*`、`/v1/square` | + +**内容 JSON**:与 `moment/entity` 的 `Content` 字段兼容;logic 不跨模块 import `model`。 + +--- + +# 0.3 与 V3 的关系 + +| 约束 | 广场 | +| --- | --- | +| C1 | Snowflake `square_posts.id` | +| C2 | HTTP → `square/logic` | +| C5 | 发帖 HTTP 200;非 `SEND_ACK` | +| C6 | 限流用 `reqctx.RealIP` | +| C7 | 通知**不进**会话 Redis ZSET | + +--- + +# 1. 总览 + +```text +客户端 + │ HTTPS /v1/square + │ POST /v1/files (square_image / square_video) + ▼ +Gin → square/logic + │ + ├─ MySQL square_posts / square_audience / square_likes + │ square_comments / square_notices + ├─ friend 校验 audience、读权限(双向好友) + ├─ RustFS perm/square/... + └─ 可选二期 deliver:queue(赞评在线 Push,朋友圈亦未强制) +``` + +--- + +# 2. 模块与目录 + +```text +internal/module/square/ +├── entity/ Post、Visibility、AuditStatus、Notice、Content… +├── model/ 一表一文件 + repo +└── logic/ publish、feed、album、get、like、comment、notice、delete、visible +internal/transport/http/handler/square/ +migrations/00xx_square.sql +``` + +依赖: + +| 依赖 | 用途 | +| --- | --- | +| `friend` | `audience_uids` 须双向好友;读帖时校验关系(拉黑则不可见/不可互动) | +| `user` | 作者卡片 | +| `upload` / `objectstore` / `id.Generator` | 同前 | + +--- + +# 3. 数据模型 + +## 3.1 `square_posts` + +| 列 | 类型 | 说明 | +| --- | --- | --- | +| `id` | BIGINT PK | Snowflake;游标 | +| `user_id` | BIGINT | 作者 | +| `kind` | SMALLINT | 1 文 2 图 3 视频 | +| `text` | VARCHAR(1000) | 可空;与媒体至少一项 | +| `content` | **VARBINARY(16384) NOT NULL** | **序列化 JSON 字节**(`json.Marshal` → `[]byte` 入库);图/视频只存 object_key,**禁止** MySQL `BLOB` 类型、禁止 `VARCHAR`/`TEXT` 存 JSON 字符串、禁止媒体二进制 | +| `poi_*` | | 可选地点 | +| `visibility` | SMALLINT | **0 全站公开 1 仅自己 2 白名单 3 黑名单(不给谁看)** | +| `audit_status` | SMALLINT | 0 待审 1 通过 2 拒绝;v1 发帖 **1** | +| `audit_note` | VARCHAR(256) | | +| `audited_at` | DATETIME(3) NULL | | +| `status` | SMALLINT | 0 正常 1 软删 | +| `like_count` / `comment_count` | INT | | +| `created_at` | DATETIME(3) | | + +索引: + +- `PRIMARY KEY (id)` +- `KEY idx_public_feed (audit_status, visibility, id)` — 全站 Feed:`audit_status=1 AND visibility IN (0,3) AND status=0` +- `KEY idx_user (user_id, id)` — 作者主页流 + +### `visibility`(枚举与朋友圈 `moment_posts.visibility` **同值同义**,广场语义见下表) + +| 值 | 名称 | 进全站 Feed 拉取 | 读者(在未拉黑前提下) | +| --- | --- | --- | --- | +| 0 | **public** | 是 | 任意登录用户 | +| 1 | **private** | 否 | 仅作者 | +| 2 | **include** | 否 | 作者 + 白名单 `square_audience` | +| 3 | **exclude** | 是 | 除黑名单 `square_audience` 外的登录用户 | + +发帖默认:`visibility=0`。 + +### `content` 列(与 `messages.content` 语义一致,物理类型不同) + +| 层 | 约定 | +| --- | --- | +| 应用层 | `entity.Content` → `Marshal()` 得到 `[]byte`,写入 `Post.Content` | +| GORM | `Content []byte` + `gorm:"column:content;type:varbinary(16384);..."` | +| MySQL | `VARBINARY(16384)`,不用 `BLOB`/`MEDIUMBLOB` | +| 上限 | 9 图 + 视频元数据 JSON 远小于 16KB;超限返回 `40001` | + +朋友圈 `moment_posts.content` 历史为 `BLOB`;广场新表按上表实现,后续若要统一 moment 可单独 migration。 + +**拉黑(全局,默认)**:不论上表哪一行,若 `BlockedEither(作者, viewer)`,则 viewer **不可见、不可赞评**;实现于 `visible` 逻辑最前,不写入 `square_audience`。 + +## 3.2 `square_audience` + +`visibility=2`(白名单)或 `visibility=3`(黑名单)时有行。语义与 `moment_audience` 一致: + +| visibility | `peer_id` 含义 | +| --- | --- | +| 2 include | **能看** 的好友 uid | +| 3 exclude | **不能看** 的好友 uid(其余登录用户能看,仍受拉黑规则约束) | + +| 列 | 说明 | +| --- | --- | +| `post_id` | 帖 id | +| `peer_id` | 好友 uid | +| PK | `(post_id, peer_id)` | + +单帖最多 **200** 人;`audience_uids` 去重;每人须与作者 **双向好友**(`friend` 校验);不含作者自己。 + +## 3.3 `square_likes` / `square_comments` + +与 `moment_likes` / `moment_comments` 同构。评论列表**不做**共同好友过滤。 + +## 3.4 `square_notices`(v1 必做) + +结构对齐 `moment_notices`: + +| 列 | 说明 | +| --- | --- | +| `id` | Snowflake PK,列表游标 | +| `to_user_id` | 接收人(帖作者或被回复者) | +| `from_user_id` | 点赞人/评论人 | +| `kind` | 1 赞 2 评论 3 回复 | +| `post_id` | 帖 id | +| `comment_id` | 赞为 0 | +| `read_at` | NULL=未读 | +| `created_at` | | + +索引:`KEY idx_to_user (to_user_id, id)`。 + +**写入时机**(与朋友圈一致,同事务或紧接 COMMIT): + +| 动作 | 通知 | +| --- | --- | +| 点赞 | `to`=帖作者,`kind=赞`;自己赞自己不写 | +| 直接评论 | `to`=帖作者,`kind=评论` | +| 回复评论 | `to`=被回复用户,`kind=回复`;若与帖作者不同可各写一条(同 moment) | + +取消赞**不**删历史通知(同朋友圈)。 + +### 未读水位(v1) + +独立表 **`square_notice_state`**(或单列进用户扩展,推荐独立表与 moment 解耦): + +| 列 | 说明 | +| --- | --- | +| `user_id` | PK | +| `last_read_notice_id` | 广场互动通知已读水位,只增不减 | +| `updated_at` | | + +`GET /v1/square/notices/unread`:`latest_id > last_read_notice_id` 则 `has_new=true`(实现可抄 `moment` `UnreadFeed`)。 + +--- + +# 3.5 读权限(单帖 / 赞 / 评 / 通知跳转) + +对浏览者 `viewer`、帖 `post`(`status=正常`,`audit_status=通过`): + +**第一步(默认)**:若 `viewer != post.user_id` 且 `BlockedEither(post.user_id, viewer)` → **拒绝**(拉黑好友不给看)。 + +**第二步**:`visibilityOK`(与 `moment/logic/visible.go` 同构,公开帖在广场表示「除黑名单外全员」): + +| visibility | 允许读 | +| --- | --- | +| public (0) | 任意登录用户 | +| private (1) | 仅作者 | +| include (2) | 作者,或 viewer 在白名单 | +| exclude (3) | 作者,或 viewer **不在** 黑名单 | + +全站 Feed:`visibility IN (0,3)` 分页拉取后,对每条执行上述过滤;不足 `limit` 时 **overfetch**(策略同朋友圈 Feed,`Overfetch`/`OverfetchRounds` 可复用常量)。 + +--- + +# 4. 分页约定 + +`cursor` = 上一页最后 `post_id`,`limit` 默认 **20**、max **50**(常量与朋友圈 `entity.ListDefault` / `ListMax` 对齐);响应 `items`、`next_cursor`、`has_more`。 + +## 4.1 全站 Feed SQL(示意) + +```sql +SELECT ... FROM square_posts +WHERE audit_status = 1 AND visibility IN (0, 3) AND status = 0 + AND (:cursor = 0 OR id < :cursor) +ORDER BY id DESC +LIMIT :batch; -- batch > limit,供拉黑/黑名单过滤 +``` + +## 4.2 作者主页流 + +`GET /v1/square/users/{uid}/posts?cursor=&limit=` + +- 拉取 `user_id = uid` 的帖(`id` 游标),在 logic 层对 `viewer` 做 §3.5 过滤(overfetch 策略可抄朋友圈 `album`)。 +- 用于:看自己的私密/部分好友帖;好友看对方允许自己看的广场帖。 + +## 4.3 与朋友圈分页 + +朋友圈 **保持** `GET /v1/moments?cursor=&limit=` 默认 **20**(`internal/module/moment/entity/kind.go` 的 `ListDefault`、OpenAPI 不变)。广场复用同一套默认/上限常量或复制相同数值(20/50)。 + +--- + +# 5. HTTP API(v1) + +均需 Bearer。前缀 `/v1/square`。 + +### 帖子 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/v1/square/posts` | **全站公开** Feed;`limit` 默认 20 | +| POST | `/v1/square/posts` | 发帖(见下) | +| GET | `/v1/square/posts/{id}` | 单帖(权限 §3.5) | +| DELETE | `/v1/square/posts/{id}` | 作者删帖 | +| GET | `/v1/square/users/{uid}/posts` | 某用户广场帖(按可见性过滤) | + +### 互动 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| PUT/DELETE | `/v1/square/posts/{id}/like` | 赞 / 取消 | +| GET/POST/DELETE | `/v1/square/posts/{id}/comments`… | 评论列表/发评/删评 | + +### 通知(v1) + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/v1/square/notices` | `cursor`、`limit`(默认 20) | +| GET | `/v1/square/notices/unread` | 角标 | +| POST | `/v1/square/notices/read` | body `{ "cursor": 0 }` 标记已读到该水位 | + +### 发帖 Body + +```json +{ + "text": "今天天气不错", + "images": [{ "key": "perm/square/image/20260922/a.jpg", "w": 1080, "h": 720 }], + "visibility": 0, + "audience_uids": [2101923649870106624], + "poi": { "name": "外滩", "lat": 31.24, "lng": 121.49 } +} +``` + +| 字段 | 说明 | +| --- | --- | +| `visibility` | `0` 公开 / `1` 仅自己 / `2` 白名单 / `3` 不给谁看 | +| `audience_uids` | `visibility=2` 或 `3` 时必填,最多 200,双向好友 | + +校验:媒体规则同朋友圈;`visibility` 为 2/3 时 `audience_uids` 非空(`entity.Visibility.NeedsAudience()`)。 + +--- + +# 6. 上传与对象存储 + +不变:`square_image` / `square_video` → `perm/square/...`。 + +--- + +# 7. 权限与安全 + +| 动作 | 规则 | +| --- | --- | +| 全站 Feed | 公开+不给谁看类帖;按读者过滤拉黑/黑名单 | +| 发帖 | 登录 + 限流 | +| 读单帖/主页流 | §3.5 + 拉黑 | +| 赞评 | 须能读该帖;限流;写 `square_notices` | +| 删帖 | 作者 | + +--- + +# 8. 实现复用 + +| 项 | 策略 | +| --- | --- | +| 客户端 | 可见范围:公开 / 仅自己 / 选好友可看 / **选好友不给看** | +| `visibility` 校验 | 与 moment **同枚举 0–3**;`validate.square.*` | +| `visible` | 复用 `visibilityOK` 语义 + 优先 `BlockedEither` | +| `notice` | 抄 `moment/logic/notice.go`、`like.go`、`comment.go` 的 `notify` 模式 | +| GORM | 嵌入结构体须 **导出**(`MessageRow` 教训) | + +--- + +# 9. 实施阶段 + +## Phase 1(本期) + +- [ ] migration:`square_posts`、`square_audience`、`square_likes`、`square_comments`、`square_notices`、`square_notice_state` +- [ ] module + HTTP + OpenAPI + i18n +- [ ] upload purpose +- [ ] 发帖:公开 / 仅自己 / 白名单 / **黑名单(不给谁看)** + 拉黑默认过滤 +- [ ] Feed + 用户主页流 + 分页默认 20 +- [ ] 赞评 + **通知列表/未读/已读** +- [ ] 测试:可见性、Feed 不含私密帖、赞评通知、拉黑不可互动 + +## Phase 2 + +- [ ] 审核 `pending` + 运营后台 +- [ ] `deliver:queue` 赞评 Push(在线设备) +- [ ] 机审 / 敏感词 + +--- + +# 10. 测试要点 + +| 场景 | 期望 | +| --- | --- | +| 发 `visibility=0` | 出现在 `GET /square/posts` | +| 发 `visibility=1` | 不出现在全站 Feed;作者主页可见 | +| 发 `visibility=2` + audience | 仅作者与名单好友可读;全站 Feed 无 | +| 发 `visibility=3` + audience | 全站 Feed 有;名单内好友不可见 | +| A 拉黑 B | B 刷 Feed 不见 A 的帖;B 单帖 GET 403/404 | +| A 公开帖 + B 在黑名单 | 仅 B 不可见 | +| B 赞 A 的公开帖 | A 的 `square_notices` 增一条;`unread` 为 true | +| B 评 A | 同;自己评自己不通知 | +| 回复 C 的评论 | C 收到回复通知;A 收到评论通知(若规则同 moment) | +| 非好友不在 audience | `GET` 单帖 404 或 403 | +| GORM INSERT | 全列写入 | + +--- + +# 11. 参考 + +- `docs/moments.md`、`internal/module/moment/logic/notice.go` +- `docs/architecture-v3.md` diff --git a/internal/bootstrap/app.go b/internal/bootstrap/app.go index fcac33d..2608d66 100644 --- a/internal/bootstrap/app.go +++ b/internal/bootstrap/app.go @@ -39,6 +39,9 @@ import ( msgmodel "spark-server/internal/module/message/model" momentlogic "spark-server/internal/module/moment/logic" momentmodel "spark-server/internal/module/moment/model" + smslogic "spark-server/internal/module/sms/logic" + squarelogic "spark-server/internal/module/square/logic" + squaremodel "spark-server/internal/module/square/model" synclogic "spark-server/internal/module/sync/logic" syncmodel "spark-server/internal/module/sync/model" uploadlogic "spark-server/internal/module/upload/logic" @@ -160,7 +163,9 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge } 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) + userRepo := wireUserRepo(db) + smsSvc := wireSMS(cfg, rdb, userRepo) + users := userlogic.NewService(userRepo, smsSvc, idgen, wireUserObjects(objects)) login := authlogic.NewLoginService(jwtSvc, users) login.Sessions = sessions var deliverQueue deliverlogic.Queue @@ -174,7 +179,8 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge files := wireFiles(cfg, objects, groups) favorites := wireFavorites(idgen, db, objects, chat) moments := wireMoments(idgen, db, objects, friends, users) - users.BindAccountCleanup(jwtSvc, sessions, accountCleaners(friends, moments, groups, favorites, inbox, mailbox, chat)...) + square := wireSquare(idgen, db, objects, friends, users) + users.BindAccountCleanup(jwtSvc, sessions, accountCleaners(friends, moments, square, groups, favorites, inbox, mailbox, chat)...) adminSvc, adminTokens := wireAdmin(cfg, db, groups, idgen) app.tcp = tcp.NewServer(log, pool, sessions, &tcp.Options{ Addr: cfg.Server.TCP.Addr, @@ -195,25 +201,38 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge Messages: messages, Sync: syncSvc, }) - 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, - Moments: moments, - Messages: messages, - Sync: syncSvc, - Admin: adminSvc, - AdminTokens: adminTokens, - Health: probe, - AuthLimiter: authLimit, - }) + app.http = httpx.New(log, cfg.Server.HTTP.Addr, httpEngineDeps( + jwtSvc, login, smsSvc, users, friends, groups, files, favorites, moments, square, messages, syncSvc, adminSvc, adminTokens, probe, authLimit, + )) return app, nil } +func httpEngineDeps( + jwtSvc *authlogic.JWT, + login *authlogic.LoginService, + sms *smslogic.Service, + users *userlogic.Service, + friends *friendlogic.Service, + groups *grouplogic.Service, + files *uploadlogic.Service, + favorites *favlogic.Service, + moments *momentlogic.Service, + square *squarelogic.Service, + messages *msglogic.Service, + syncSvc *synclogic.Service, + adminSvc *adminlogic.Service, + adminTokens *adminlogic.TokenService, + probe *Probe, + authLimit *ratelimit.Limiter, +) *httpx.EngineDeps { + return &httpx.EngineDeps{ + Auth: jwtSvc, Login: login, SMS: sms, Users: users, Friends: friends, Groups: groups, + Files: files, Favorites: favorites, Moments: moments, Square: square, + Messages: messages, Sync: syncSvc, Admin: adminSvc, AdminTokens: adminTokens, + Health: probe, AuthLimiter: authLimit, + } +} + func wireAdmin(cfg *Config, db *mysql.DB, groups *grouplogic.Service, idgen *id.Generator) (*adminlogic.Service, *adminlogic.TokenService) { tokens := adminlogic.NewTokenService(cfg.Admin.JWTSecret) var repo *adminmodel.Repo @@ -234,24 +253,18 @@ 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 +func wireUserRepo(db *mysql.DB) userlogic.Repository { if db != nil && db.Ready() { - repo = usermodel.NewRepo(db.Gorm()) - } else { - repo = usermodel.NewMemoryRepo() + return usermodel.NewRepo(db.Gorm()) } - var codes userlogic.CodeStore - if rdb != nil && rdb.Ready() { - codes = usermodel.NewRedisCodes(rdb.RDB()) - } else { - codes = usermodel.NewMemoryCodes() - } - var objects userlogic.Objects + return usermodel.NewMemoryRepo() +} + +func wireUserObjects(store *objectstore.Store) userlogic.Objects { if store != nil && store.Ready() { - objects = store + return store } - return userlogic.NewService(repo, codes, idgen, objects) + return nil } type chatStores struct { @@ -324,6 +337,23 @@ func wireFavorites(idgen *id.Generator, db *mysql.DB, store *objectstore.Store, return favlogic.NewService(repo, chat.msgs, chat.chats, objects, idgen) } +func wireSquare(idgen *id.Generator, db *mysql.DB, store *objectstore.Store, friends *friendlogic.Service, users *userlogic.Service) *squarelogic.Service { + if idgen == nil || friends == nil || users == nil { + return nil + } + var repo squarelogic.Repository + if db != nil && db.Ready() { + repo = squaremodel.NewRepo(db.Gorm()) + } else { + repo = squaremodel.NewMemoryRepo() + } + var objects squarelogic.Objects + if store != nil && store.Ready() { + objects = store + } + return squarelogic.NewService(repo, friends, users, objects, idgen) +} + func wireMoments(idgen *id.Generator, db *mysql.DB, store *objectstore.Store, friends *friendlogic.Service, users *userlogic.Service) *momentlogic.Service { if idgen == nil || friends == nil || users == nil { return nil @@ -384,6 +414,7 @@ func wireMessages(idgen *id.Generator, friends *friendlogic.Service, groups *gro func accountCleaners( friends *friendlogic.Service, moments *momentlogic.Service, + square *squarelogic.Service, groups *grouplogic.Service, favorites *favlogic.Service, inbox inboxlogic.Store, @@ -397,6 +428,9 @@ func accountCleaners( if moments != nil { out = append(out, moments) } + if square != nil { + out = append(out, square) + } if groups != nil { out = append(out, groups) } diff --git a/internal/bootstrap/config.go b/internal/bootstrap/config.go index 42643cd..7fdf170 100644 --- a/internal/bootstrap/config.go +++ b/internal/bootstrap/config.go @@ -25,6 +25,7 @@ type Config struct { RateLimit RateLimitConfig `yaml:"rate_limit"` Message MessageConfig `yaml:"message"` ObjectStorage ObjectStorageConfig `yaml:"object_storage"` + SMS SMSConfig `yaml:"sms"` } // AdminConfig is popadmin JWT validation (separate from client jwt). @@ -140,6 +141,37 @@ type MessageConfig struct { MsgIDExpireMS int64 `yaml:"msgid_expire_ms"` } +type SMSConfig struct { + SMTP SMTPConfig `yaml:"smtp"` + AliyunSMS AliyunSMSConfig `yaml:"aliyun_sms"` +} + +type SMTPConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Username string `yaml:"username"` + Password string `yaml:"password"` + From string `yaml:"from"` +} + +type AliyunSMSConfig struct { + AccessKeyID string `yaml:"access_key_id"` + AccessKeySecret string `yaml:"access_key_secret"` + SignName string `yaml:"sign_name"` + Templates AliyunSMSTemplatesConfig `yaml:"templates"` +} + +type AliyunSMSTemplatesConfig struct { + Login AliyunCarrierTemplates `yaml:"login"` + AccountDelete AliyunCarrierTemplates `yaml:"account_delete"` +} + +type AliyunCarrierTemplates struct { + Mobile string `yaml:"mobile"` + Unicom string `yaml:"unicom"` + Telecom string `yaml:"telecom"` +} + type JWTConfig struct { Secret string `yaml:"secret"` Issuer string `yaml:"issuer"` @@ -254,6 +286,15 @@ func applyDefaults(cfg *Config) { if v := os.Getenv("SPARK_S3_BUCKET"); v != "" { cfg.ObjectStorage.Bucket = v } + if v := os.Getenv("SPARK_SMTP_PASSWORD"); v != "" { + cfg.SMS.SMTP.Password = v + } + if v := os.Getenv("SPARK_ALIYUN_SMS_ACCESS_KEY_ID"); v != "" { + cfg.SMS.AliyunSMS.AccessKeyID = v + } + if v := os.Getenv("SPARK_ALIYUN_SMS_ACCESS_KEY_SECRET"); v != "" { + cfg.SMS.AliyunSMS.AccessKeySecret = v + } if cfg.ObjectStorage.Bucket == "" { cfg.ObjectStorage.Bucket = "spark" } @@ -302,5 +343,11 @@ func (cfg *Config) validate() error { return fmt.Errorf("prod: object_storage access_key and secret_key are required") } } + if strings.TrimSpace(cfg.SMS.SMTP.Host) == "" { + return fmt.Errorf("prod: sms.smtp.host is required") + } + if strings.TrimSpace(cfg.SMS.SMTP.Username) == "" || strings.TrimSpace(cfg.SMS.SMTP.Password) == "" { + return fmt.Errorf("prod: sms.smtp.username and password are required") + } return nil } diff --git a/internal/bootstrap/health_test.go b/internal/bootstrap/health_test.go index b9bb6c5..dc09a5a 100644 --- a/internal/bootstrap/health_test.go +++ b/internal/bootstrap/health_test.go @@ -36,6 +36,7 @@ func TestParseEnvAndProductionValidate(t *testing.T) { t.Fatal("prod default admin jwt") } prod.Admin.JWTSecret = "production-admin-jwt-secret-not-default" + prod.SMS = SMSConfig{SMTP: SMTPConfig{Host: "smtpdm.aliyun.com", Port: 465, Username: "noreply@sparkmail.wyrs365.com", Password: "test"}} if err := prod.normalize(); err != nil { t.Fatal(err) } diff --git a/internal/bootstrap/sms.go b/internal/bootstrap/sms.go new file mode 100644 index 0000000..bd967a4 --- /dev/null +++ b/internal/bootstrap/sms.go @@ -0,0 +1,69 @@ +package bootstrap + +import ( + "context" + + redisx "spark-server/internal/infrastructure/redis" + smsinfra "spark-server/internal/infrastructure/sms" + smtpinfra "spark-server/internal/infrastructure/smtp" + smsentity "spark-server/internal/module/sms/entity" + smslogic "spark-server/internal/module/sms/logic" + smsmodel "spark-server/internal/module/sms/model" + userlogic "spark-server/internal/module/user/logic" +) + +type repoEmailLookup struct { + repo userlogic.Repository +} + +func (r repoEmailLookup) EmailByUserID(ctx context.Context, userID int64) (string, error) { + if r.repo == nil { + return "", nil + } + return r.repo.GetEmail(ctx, userID) +} + +func wireSMS(cfg *Config, rdb *redisx.Client, repo userlogic.Repository) *smslogic.Service { + var store smslogic.CodeStore + if rdb != nil && rdb.Ready() { + store = smsmodel.NewRedisStore(rdb.RDB()) + } else { + store = smsmodel.NewMemoryStore() + } + var mail smslogic.EmailSender + if m := smtpinfra.New(smtpinfra.Options{ + Host: cfg.SMS.SMTP.Host, + Port: cfg.SMS.SMTP.Port, + Username: cfg.SMS.SMTP.Username, + Password: cfg.SMS.SMTP.Password, + From: cfg.SMS.SMTP.From, + }); m.Ready() { + mail = m + } + return smslogic.NewService(store, mail, wirePhoneProviders(cfg), repoEmailLookup{repo: repo}) +} + +func wirePhoneProviders(cfg *Config) []smslogic.PhoneCarrierSender { + a := cfg.SMS.AliyunSMS + if a.AccessKeyID == "" || a.AccessKeySecret == "" || a.SignName == "" { + return nil + } + opts := smsinfra.AliyunOptions{ + AccessKeyID: a.AccessKeyID, + AccessKeySecret: a.AccessKeySecret, + SignName: a.SignName, + } + loginTpl := a.Templates.Login + delTpl := a.Templates.AccountDelete + return []smslogic.PhoneCarrierSender{ + smsinfra.NewCarrierGateway(smsentity.CarrierMobile, opts, smsinfra.TemplateSet{ + Login: loginTpl.Mobile, AccountDelete: delTpl.Mobile, + }), + smsinfra.NewCarrierGateway(smsentity.CarrierUnicom, opts, smsinfra.TemplateSet{ + Login: loginTpl.Unicom, AccountDelete: delTpl.Unicom, + }), + smsinfra.NewCarrierGateway(smsentity.CarrierTelecom, opts, smsinfra.TemplateSet{ + Login: loginTpl.Telecom, AccountDelete: delTpl.Telecom, + }), + } +} diff --git a/internal/infrastructure/sms/aliyun.go b/internal/infrastructure/sms/aliyun.go new file mode 100644 index 0000000..7bd358e --- /dev/null +++ b/internal/infrastructure/sms/aliyun.go @@ -0,0 +1,183 @@ +// Package sms implements Aliyun SMS OTP delivery per carrier segment. +package sms + +import ( + "context" + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" + + smsentity "spark-server/internal/module/sms/entity" + smslogic "spark-server/internal/module/sms/logic" + "spark-server/internal/pkg/errs" +) + +const dysmsEndpoint = "https://dysmsapi.aliyuncs.com/" + +// TemplateSet holds Aliyun template codes per purpose for one carrier. +type TemplateSet struct { + Login string + AccountDelete string +} + +// AliyunOptions configures RPC access to dysmsapi. +type AliyunOptions struct { + AccessKeyID string + AccessKeySecret string + SignName string +} + +// CarrierGateway sends SMS for one operator using distinct templates. +type CarrierGateway struct { + carrier smsentity.Carrier + client *AliyunClient + signName string + templates TemplateSet +} + +func NewCarrierGateway(carrier smsentity.Carrier, opts AliyunOptions, templates TemplateSet) *CarrierGateway { + return &CarrierGateway{ + carrier: carrier, + client: NewAliyunClient(opts), + signName: strings.TrimSpace(opts.SignName), + templates: templates, + } +} + +func (g *CarrierGateway) Carrier() smsentity.Carrier { + if g == nil { + return smsentity.CarrierUnknown + } + return g.carrier +} + +func (g *CarrierGateway) SendOTP(ctx context.Context, phone string, purpose smsentity.Purpose, code string) error { + if g == nil || g.client == nil || !g.client.Ready() { + return errs.ErrAvailable + } + tpl := g.templateFor(purpose) + if tpl == "" || g.signName == "" { + return errs.ErrAvailable + } + params := map[string]string{ + "PhoneNumbers": strings.TrimPrefix(phone, "+86"), + "SignName": g.signName, + "TemplateCode": tpl, + "TemplateParam": fmt.Sprintf(`{"code":"%s"}`, code), + } + return g.client.SendSMS(ctx, params) +} + +func (g *CarrierGateway) templateFor(purpose smsentity.Purpose) string { + switch purpose { + case smsentity.PurposeAccountDelete: + return strings.TrimSpace(g.templates.AccountDelete) + default: + return strings.TrimSpace(g.templates.Login) + } +} + +// AliyunClient calls SendSms on dysmsapi.aliyuncs.com. +type AliyunClient struct { + accessKeyID string + accessKeySecret string + http *http.Client +} + +func NewAliyunClient(opts AliyunOptions) *AliyunClient { + return &AliyunClient{ + accessKeyID: strings.TrimSpace(opts.AccessKeyID), + accessKeySecret: strings.TrimSpace(opts.AccessKeySecret), + http: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (c *AliyunClient) Ready() bool { + return c != nil && c.accessKeyID != "" && c.accessKeySecret != "" +} + +func (c *AliyunClient) SendSMS(ctx context.Context, extra map[string]string) error { + if c == nil || !c.Ready() { + return errs.ErrAvailable + } + params := map[string]string{ + "Action": "SendSms", + "Version": "2017-05-25", + "RegionId": "cn-hangzhou", + "Format": "JSON", + "SignatureMethod": "HMAC-SHA1", + "SignatureVersion": "1.0", + "SignatureNonce": fmt.Sprintf("%d", time.Now().UnixNano()), + "Timestamp": time.Now().UTC().Format("2006-01-02T15:04:05Z"), + "AccessKeyId": c.accessKeyID, + } + for k, v := range extra { + params[k] = v + } + params["Signature"] = signParams(params, c.accessKeySecret) + form := url.Values{} + for k, v := range params { + form.Set(k, v) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, dysmsEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return errs.ErrOperatFail.WithError("dysms-req", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := c.http.Do(req) + if err != nil { + return errs.ErrOperatFail.WithError("dysms-http", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + var out struct { + Code string `json:"Code"` + Message string `json:"Message"` + } + if err := json.Unmarshal(body, &out); err != nil { + return errs.ErrOperatFail.WithError("dysms-decode", err) + } + if out.Code != "OK" { + return errs.ErrOperatFail.WithMsg(out.Message) + } + return nil +} + +func signParams(params map[string]string, secret string) string { + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, k) + } + sort.Strings(keys) + var canonical strings.Builder + for i, k := range keys { + if i > 0 { + canonical.WriteByte('&') + } + canonical.WriteString(pctEncode(k)) + canonical.WriteByte('=') + canonical.WriteString(pctEncode(params[k])) + } + stringToSign := "POST&" + pctEncode("/") + "&" + pctEncode(canonical.String()) + mac := hmac.New(sha1.New, []byte(secret+"&")) + mac.Write([]byte(stringToSign)) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} + +func pctEncode(s string) string { + enc := url.QueryEscape(s) + enc = strings.ReplaceAll(enc, "+", "%20") + enc = strings.ReplaceAll(enc, "*", "%2A") + enc = strings.ReplaceAll(enc, "%7E", "~") + return enc +} + +var _ smslogic.PhoneCarrierSender = (*CarrierGateway)(nil) diff --git a/internal/infrastructure/smtp/mailer.go b/internal/infrastructure/smtp/mailer.go new file mode 100644 index 0000000..31031bf --- /dev/null +++ b/internal/infrastructure/smtp/mailer.go @@ -0,0 +1,140 @@ +// Package smtp sends OTP mail via Aliyun DirectMail SMTP. +package smtp + +import ( + "bytes" + "context" + "crypto/tls" + "fmt" + "net" + "net/smtp" + "strings" + "time" + + smsentity "spark-server/internal/module/sms/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" +) + +// Options configures the DirectMail SMTP relay. +type Options struct { + Host string + Port int + Username string + Password string + From string +} + +// Mailer implements smslogic.EmailSender. +type Mailer struct { + host string + addr string + username string + password string + from string +} + +func New(opts Options) *Mailer { + host := strings.TrimSpace(opts.Host) + port := opts.Port + if port <= 0 { + port = 465 + } + from := strings.TrimSpace(opts.From) + if from == "" { + from = strings.TrimSpace(opts.Username) + } + return &Mailer{ + host: host, + addr: fmt.Sprintf("%s:%d", host, port), + username: strings.TrimSpace(opts.Username), + password: opts.Password, + from: from, + } +} + +func (m *Mailer) Ready() bool { + return m != nil && m.host != "" && m.username != "" && m.password != "" +} + +func (m *Mailer) SendOTP(ctx context.Context, to string, purpose smsentity.Purpose, code, lang string) error { + if m == nil || !m.Ready() { + return errs.ErrAvailable + } + to = strings.TrimSpace(to) + if to == "" { + return errs.ErrInvalidArgument + } + subject, body := mailCopy(purpose, code, i18n.Parse(lang)) + msg := buildMessage(m.from, to, subject, body) + return m.send(ctx, to, msg) +} + +func (m *Mailer) send(ctx context.Context, to string, msg []byte) error { + dialer := &net.Dialer{Timeout: 15 * time.Second} + conn, err := tls.DialWithDialer(dialer, "tcp", m.addr, &tls.Config{ServerName: m.host, MinVersion: tls.VersionTLS12}) + if err != nil { + return errs.ErrOperatFail.WithError("smtp-dial", err) + } + defer conn.Close() + if err := conn.SetDeadline(deadline(ctx)); err != nil { + return errs.ErrOperatFail.WithError("smtp-deadline", err) + } + c, err := smtp.NewClient(conn, m.host) + if err != nil { + return errs.ErrOperatFail.WithError("smtp-client", err) + } + defer c.Close() + auth := smtp.PlainAuth("", m.username, m.password, m.host) + if err := c.Auth(auth); err != nil { + return errs.ErrOperatFail.WithError("smtp-auth", err) + } + if err := c.Mail(m.username); err != nil { + return errs.ErrOperatFail.WithError("smtp-mail-from", err) + } + if err := c.Rcpt(to); err != nil { + return errs.ErrOperatFail.WithError("smtp-rcpt", err) + } + w, err := c.Data() + if err != nil { + return errs.ErrOperatFail.WithError("smtp-data", err) + } + if _, err := w.Write(msg); err != nil { + return errs.ErrOperatFail.WithError("smtp-write", err) + } + if err := w.Close(); err != nil { + return errs.ErrOperatFail.WithError("smtp-data-close", err) + } + return c.Quit() +} + +func deadline(ctx context.Context) time.Time { + if d, ok := ctx.Deadline(); ok { + return d + } + return time.Now().Add(30 * time.Second) +} + +func buildMessage(from, to, subject, body string) []byte { + var buf bytes.Buffer + buf.WriteString("From: " + from + "\r\n") + buf.WriteString("To: " + to + "\r\n") + buf.WriteString("Subject: " + subject + "\r\n") + buf.WriteString("MIME-Version: 1.0\r\n") + buf.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") + buf.WriteString("\r\n") + buf.WriteString(body) + return buf.Bytes() +} + +func mailCopy(purpose smsentity.Purpose, code string, lang i18n.Lang) (subject, body string) { + switch purpose { + case smsentity.PurposeAccountDelete: + subject = i18n.T(lang, i18n.KeySMSEmailSubjectDelete, nil) + body = i18n.T(lang, i18n.KeySMSEmailBodyDelete, map[string]string{"code": code}) + default: + subject = i18n.T(lang, i18n.KeySMSEmailSubjectLogin, nil) + body = i18n.T(lang, i18n.KeySMSEmailBodyLogin, map[string]string{"code": code}) + } + return subject, body +} diff --git a/internal/module/auth/logic/login.go b/internal/module/auth/logic/login.go index d88c720..6b566ee 100644 --- a/internal/module/auth/logic/login.go +++ b/internal/module/auth/logic/login.go @@ -5,7 +5,6 @@ import ( 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" @@ -15,7 +14,6 @@ import ( // 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. @@ -28,15 +26,6 @@ type LoginInput struct { 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"` } @@ -82,19 +71,6 @@ func (s *LoginService) Login(ctx context.Context, in *LoginInput) (*authentity.I 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 diff --git a/internal/module/auth/logic/login_test.go b/internal/module/auth/logic/login_test.go index 4bae058..861a76b 100644 --- a/internal/module/auth/logic/login_test.go +++ b/internal/module/auth/logic/login_test.go @@ -5,6 +5,9 @@ import ( "strings" "testing" + "spark-server/internal/infrastructure/id" + smslogic "spark-server/internal/module/sms/logic" + smsmodel "spark-server/internal/module/sms/model" userlogic "spark-server/internal/module/user/logic" usermodel "spark-server/internal/module/user/model" "spark-server/internal/pkg/errs" @@ -21,7 +24,7 @@ func TestLoginServiceValidatesAndIssues(t *testing.T) { _, 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, "请填写验证码") { + if !ok || app.Code != errs.ErrInvalidArgument.Code || !strings.Contains(app.Msg, "Please enter the verification code") { t.Fatalf("validate %+v", err) } @@ -41,7 +44,7 @@ func TestLoginServiceValidatesAndIssues(t *testing.T) { } } -func TestLoginServiceSendRefreshLogout(t *testing.T) { +func TestLoginServiceRefreshLogout(t *testing.T) { j, err := NewJWT(&Options{Secret: "test-secret-test-secret", Issuer: "spark-im"}) if err != nil { t.Fatal(err) @@ -51,10 +54,6 @@ func TestLoginServiceSendRefreshLogout(t *testing.T) { svc := NewLoginService(j, users) svc.Sessions = kicker ctx := reqctx.With(t.Context(), &reqctx.RequestContext{RealIP: "203.0.113.10"}) - sent, err := svc.SendEmailCode(ctx, SendCodeInput{Email: "test01@spark.com"}) - if err != nil || sent == nil || sent.ExpiresIn != userlogic.EmailCodeTTLSeconds { - t.Fatalf("code %+v %v", sent, err) - } tok, err := svc.Login(ctx, &LoginInput{Email: "test01@spark.com", VerifyCode: "any", DeviceID: 7, DeviceType: "ios"}) if err != nil { t.Fatal(err) @@ -74,9 +73,6 @@ func TestLoginServiceSendRefreshLogout(t *testing.T) { if kicker.n != 1 { t.Fatalf("kick %d", kicker.n) } - if _, err := svc.SendEmailCode(ctx, SendCodeInput{}); err == nil { - t.Fatal("empty email") - } if _, err := (*LoginService)(nil).Refresh(ctx, RefreshInput{RefreshToken: "x"}); !errors.Is(err, errs.ErrUnauthorized) { t.Fatalf("nil refresh %v", err) } @@ -122,8 +118,13 @@ func TestLoginServiceRecordsContextDevice(t *testing.T) { t.Fatal(err) } repo := usermodel.NewMemoryRepo() - ids := seqIDs{n: 1000} - users := userlogic.NewService(repo, usermodel.NewMemoryCodes(), &ids, nil) + idgen, err := id.New(1) + if err != nil { + t.Fatal(err) + } + store := smsmodel.NewMemoryStore() + smsSvc := smslogic.NewService(store, nil, nil, nil) + users := userlogic.NewService(repo, smsSvc, idgen, nil) svc := NewLoginService(j, users) ctx := reqctx.With(t.Context(), &reqctx.RequestContext{ RealIP: "198.51.100.9", Language: "en", AppVersion: "2.1.0", @@ -142,10 +143,3 @@ func TestLoginServiceRecordsContextDevice(t *testing.T) { t.Fatalf("%+v", dev) } } - -type seqIDs struct{ n int64 } - -func (s *seqIDs) NextID() (int64, error) { - s.n++ - return s.n, nil -} diff --git a/internal/module/auth/logic/static.go b/internal/module/auth/logic/static.go index 48c3dda..55f6d25 100644 --- a/internal/module/auth/logic/static.go +++ b/internal/module/auth/logic/static.go @@ -43,5 +43,3 @@ func (s StaticUser) AuthenticateEmail(_ context.Context, email, verifyCode strin } return &userentity.User{ID: s.UserID, SparkID: sparkID, Status: userentity.StatusActive}, nil } - -func (s StaticUser) SendEmailCode(context.Context, string) error { return nil } diff --git a/internal/module/friend/logic/friend_test.go b/internal/module/friend/logic/friend_test.go index ab4a84d..57ced55 100644 --- a/internal/module/friend/logic/friend_test.go +++ b/internal/module/friend/logic/friend_test.go @@ -16,7 +16,7 @@ import ( msgmodel "spark-server/internal/module/message/model" userentity "spark-server/internal/module/user/entity" userlogic "spark-server/internal/module/user/logic" - usermodel "spark-server/internal/module/user/model" + "spark-server/internal/module/user/logictest" "spark-server/internal/pkg/errs" ) @@ -37,7 +37,7 @@ func testFriends(t *testing.T) (*Service, *userlogic.Service, *captureGreeter) { if err != nil { t.Fatal(err) } - users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) + users := logictest.Users(ids, nil) g := &captureGreeter{} return NewService(friendmodel.NewMemoryRepo(), users, ids, g), users, g } diff --git a/internal/module/group/logic/group_test.go b/internal/module/group/logic/group_test.go index 366d17a..60424b8 100644 --- a/internal/module/group/logic/group_test.go +++ b/internal/module/group/logic/group_test.go @@ -20,7 +20,7 @@ import ( msgmodel "spark-server/internal/module/message/model" userentity "spark-server/internal/module/user/entity" userlogic "spark-server/internal/module/user/logic" - usermodel "spark-server/internal/module/user/model" + "spark-server/internal/module/user/logictest" "spark-server/internal/pkg/errs" ) @@ -46,7 +46,7 @@ func testGroupsStore(t *testing.T, objects testObjects) (*Service, *friendlogic. userObjs = objects groupObjs = objects } - users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, userObjs) + users := logictest.Users(ids, userObjs) friends := friendlogic.NewService( friendmodel.NewMemoryRepo(), users, ids, friendlogic.NewGreeter(convlogic.NewP2PService(convmodel.NewMemoryP2P(), ids), msgmodel.NewMemoryRepo(), ids, delivermodel.NewMemoryQueue()), diff --git a/internal/module/message/logic/message_test.go b/internal/module/message/logic/message_test.go index 8069f6d..33a3812 100644 --- a/internal/module/message/logic/message_test.go +++ b/internal/module/message/logic/message_test.go @@ -18,7 +18,7 @@ import ( msgmodel "spark-server/internal/module/message/model" userentity "spark-server/internal/module/user/entity" userlogic "spark-server/internal/module/user/logic" - usermodel "spark-server/internal/module/user/model" + "spark-server/internal/module/user/logictest" "spark-server/internal/pkg/errs" "spark-server/internal/pkg/reqctx" ) @@ -29,7 +29,7 @@ func testMessageSvc(t *testing.T) (*Service, *userlogic.Service, *friendlogic.Se if err != nil { t.Fatal(err) } - users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) + users := logictest.Users(ids, nil) chats := convmodel.NewMemoryP2P() p2p := convlogic.NewP2PService(chats, ids) q := delivermodel.NewMemoryQueue() diff --git a/internal/module/meta/entity/enum.go b/internal/module/meta/entity/enum.go index afa4c52..dc161a5 100644 --- a/internal/module/meta/entity/enum.go +++ b/internal/module/meta/entity/enum.go @@ -34,4 +34,6 @@ type Catalog struct { MomentStatus []Item `json:"moment_status"` MomentNoticeKind []Item `json:"moment_notice_kind"` MomentVisibleDays []Item `json:"moment_visible_days"` + VerificationPurpose []Item `json:"verification_purpose"` + PhoneCarrier []Item `json:"phone_carrier"` } diff --git a/internal/module/meta/logic/enums.go b/internal/module/meta/logic/enums.go index a1d4283..8b3984e 100644 --- a/internal/module/meta/logic/enums.go +++ b/internal/module/meta/logic/enums.go @@ -35,6 +35,8 @@ func Catalog(lang i18n.Lang) *entity.Catalog { MomentStatus: momentStatus(lang), MomentNoticeKind: momentNoticeKind(lang), MomentVisibleDays: momentVisibleDays(lang), + VerificationPurpose: verificationPurpose(lang), + PhoneCarrier: phoneCarrier(lang), } } diff --git a/internal/module/meta/logic/enums_sms.go b/internal/module/meta/logic/enums_sms.go new file mode 100644 index 0000000..84aab9d --- /dev/null +++ b/internal/module/meta/logic/enums_sms.go @@ -0,0 +1,22 @@ +package logic + +import ( + smsentity "spark-server/internal/module/sms/entity" + "spark-server/internal/module/meta/entity" + "spark-server/internal/pkg/i18n" +) + +func verificationPurpose(lang i18n.Lang) []entity.Item { + return []entity.Item{ + item(lang, "verification_purpose", "login", int(smsentity.PurposeLogin)), + item(lang, "verification_purpose", "account_delete", int(smsentity.PurposeAccountDelete)), + } +} + +func phoneCarrier(lang i18n.Lang) []entity.Item { + return []entity.Item{ + item(lang, "phone_carrier", "mobile", int(smsentity.CarrierMobile)), + item(lang, "phone_carrier", "unicom", int(smsentity.CarrierUnicom)), + item(lang, "phone_carrier", "telecom", int(smsentity.CarrierTelecom)), + } +} diff --git a/internal/module/meta/logic/enums_wire.go b/internal/module/meta/logic/enums_wire.go index 81bf0a0..99869a4 100644 --- a/internal/module/meta/logic/enums_wire.go +++ b/internal/module/meta/logic/enums_wire.go @@ -39,6 +39,7 @@ func envelopeCode(lang i18n.Lang) []entity.Item { code(lang, "invalid_proxy_header", errs.ErrProxyHeader), code(lang, "unauthorized", errs.ErrUnauthorized), code(lang, "not_authenticated", errs.ErrNotAuthenticated), + code(lang, "verify_code_invalid", errs.ErrVerifyCodeInvalid), code(lang, "forbidden", errs.ErrForbidden), code(lang, "not_found", errs.ErrNotFound), code(lang, "media_expired", errs.ErrMediaExpired), diff --git a/internal/module/moment/logic/moment_test.go b/internal/module/moment/logic/moment_test.go index 1a4760c..4992e5e 100644 --- a/internal/module/moment/logic/moment_test.go +++ b/internal/module/moment/logic/moment_test.go @@ -14,7 +14,7 @@ import ( momentmodel "spark-server/internal/module/moment/model" userentity "spark-server/internal/module/user/entity" userlogic "spark-server/internal/module/user/logic" - usermodel "spark-server/internal/module/user/model" + "spark-server/internal/module/user/logictest" "spark-server/internal/pkg/errs" "spark-server/internal/pkg/reqctx" ) @@ -34,7 +34,7 @@ func testMoment(t *testing.T) (*Service, *friendlogic.Service, *userlogic.Servic if err != nil { t.Fatal(err) } - users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) + users := logictest.Users(ids, nil) friends := friendlogic.NewService(friendmodel.NewMemoryRepo(), users, ids, nil) svc := NewService(momentmodel.NewMemoryRepo(), friends, users, &memObj{}, ids) return svc, friends, users diff --git a/internal/module/sms/entity/carrier.go b/internal/module/sms/entity/carrier.go new file mode 100644 index 0000000..52e5b0c --- /dev/null +++ b/internal/module/sms/entity/carrier.go @@ -0,0 +1,15 @@ +package entity + +// Carrier is a mainland China mobile network operator used to pick an SMS template. +type Carrier int8 + +const ( + CarrierUnknown Carrier = 0 + CarrierMobile Carrier = 1 + CarrierUnicom Carrier = 2 + CarrierTelecom Carrier = 3 +) + +func (c Carrier) Valid() bool { + return c >= CarrierMobile && c <= CarrierTelecom +} diff --git a/internal/module/sms/entity/channel.go b/internal/module/sms/entity/channel.go new file mode 100644 index 0000000..91a010e --- /dev/null +++ b/internal/module/sms/entity/channel.go @@ -0,0 +1,18 @@ +package entity + +// Channel is the delivery path for an OTP. +type Channel int8 + +const ( + ChannelEmail Channel = 1 + ChannelPhone Channel = 2 +) + +func (c Channel) Valid() bool { + switch c { + case ChannelEmail, ChannelPhone: + return true + default: + return false + } +} diff --git a/internal/module/sms/entity/phone.go b/internal/module/sms/entity/phone.go new file mode 100644 index 0000000..46e9300 --- /dev/null +++ b/internal/module/sms/entity/phone.go @@ -0,0 +1,54 @@ +package entity + +import ( + "regexp" + "strings" +) + +var digitsOnly = regexp.MustCompile(`^\d+$`) + +// NormalizePhone returns E.164-style +86XXXXXXXXXXX for mainland mobile, or "" if invalid. +func NormalizePhone(raw string) string { + s := strings.TrimSpace(raw) + s = strings.ReplaceAll(s, " ", "") + s = strings.ReplaceAll(s, "-", "") + if s == "" { + return "" + } + if strings.HasPrefix(s, "+86") { + s = s[3:] + } else if strings.HasPrefix(s, "86") && len(s) > 11 { + s = s[2:] + } + if len(s) != 11 || s[0] != '1' || !digitsOnly.MatchString(s) { + return "" + } + return "+86" + s +} + +// DetectCarrier maps a normalized +86 number to an operator bucket. +func DetectCarrier(normalized string) Carrier { + if len(normalized) < 4 { + return CarrierUnknown + } + d := normalized + d = strings.TrimPrefix(d, "+86") + if len(d) < 3 { + return CarrierUnknown + } + p3 := d[:3] + switch p3 { + case "134", "135", "136", "137", "138", "139", "147", "148", "150", "151", "152", "157", "158", "159", + "172", "178", "182", "183", "184", "187", "188", "195", "197", "198": + return CarrierMobile + case "130", "131", "132", "145", "146", "155", "156", "166", "167", "171", "175", "176", "185", "186", "196": + return CarrierUnicom + case "133", "149", "153", "173", "174", "177", "180", "181", "189", "190", "191", "193", "199": + return CarrierTelecom + default: + if strings.HasPrefix(p3, "14") || strings.HasPrefix(p3, "16") { + return CarrierUnicom + } + return CarrierUnknown + } +} diff --git a/internal/module/sms/entity/purpose.go b/internal/module/sms/entity/purpose.go new file mode 100644 index 0000000..3f70c1f --- /dev/null +++ b/internal/module/sms/entity/purpose.go @@ -0,0 +1,18 @@ +package entity + +// Purpose classifies why an OTP was issued. +type Purpose int8 + +const ( + PurposeLogin Purpose = 1 + PurposeAccountDelete Purpose = 2 +) + +func (p Purpose) Valid() bool { + switch p { + case PurposeLogin, PurposeAccountDelete: + return true + default: + return false + } +} diff --git a/internal/module/sms/entity/target.go b/internal/module/sms/entity/target.go new file mode 100644 index 0000000..2c1eded --- /dev/null +++ b/internal/module/sms/entity/target.go @@ -0,0 +1,31 @@ +package entity + +import ( + "net/mail" + "strings" + + userentity "spark-server/internal/module/user/entity" +) + +// ParseTarget classifies a single client string as email or mainland mobile. +func ParseTarget(raw string) (Channel, string, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0, "", false + } + if strings.Contains(raw, "@") { + email := userentity.NormalizeEmail(raw) + if email == "" { + return 0, "", false + } + if _, err := mail.ParseAddress(email); err != nil { + return 0, "", false + } + return ChannelEmail, email, true + } + phone := NormalizePhone(raw) + if phone == "" { + return 0, "", false + } + return ChannelPhone, phone, true +} diff --git a/internal/module/sms/entity/target_test.go b/internal/module/sms/entity/target_test.go new file mode 100644 index 0000000..6955f9a --- /dev/null +++ b/internal/module/sms/entity/target_test.go @@ -0,0 +1,17 @@ +package entity + +import "testing" + +func TestParseTarget(t *testing.T) { + ch, v, ok := ParseTarget("Ada@Example.com") + if !ok || ch != ChannelEmail || v != "ada@example.com" { + t.Fatalf("email %+v %q %v", ch, v, ok) + } + ch, v, ok = ParseTarget("13812345678") + if !ok || ch != ChannelPhone || v != "+8613812345678" { + t.Fatalf("phone %+v %q %v", ch, v, ok) + } + if _, _, ok = ParseTarget("not-valid"); ok { + t.Fatal("junk") + } +} diff --git a/internal/module/sms/logic/provider.go b/internal/module/sms/logic/provider.go new file mode 100644 index 0000000..5000208 --- /dev/null +++ b/internal/module/sms/logic/provider.go @@ -0,0 +1,18 @@ +package logic + +import ( + "context" + + "spark-server/internal/module/sms/entity" +) + +// EmailSender delivers OTP mail over SMTP (Aliyun DirectMail). +type EmailSender interface { + SendOTP(ctx context.Context, to string, purpose entity.Purpose, code string, lang string) error +} + +// PhoneCarrierSender sends SMS for one operator segment. +type PhoneCarrierSender interface { + Carrier() entity.Carrier + SendOTP(ctx context.Context, phone string, purpose entity.Purpose, code string) error +} diff --git a/internal/module/sms/logic/send.go b/internal/module/sms/logic/send.go new file mode 100644 index 0000000..0179a77 --- /dev/null +++ b/internal/module/sms/logic/send.go @@ -0,0 +1,178 @@ +package logic + +import ( + "context" + "strings" + + smsentity "spark-server/internal/module/sms/entity" + userentity "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" +) + +// SendCodeInput is the HTTP body for POST /v1/sms/codes. +type SendCodeInput struct { + Purpose smsentity.Purpose `json:"purpose" validate:"required,oneof=1 2" label:"field.verification_purpose" errmsg:"required:validate.sms.purpose_required;oneof:validate.sms.purpose_invalid"` + Target string `json:"target" validate:"required" label:"field.verification_target" errmsg:"required:validate.sms.target_required"` +} + +type SentCode struct { + ExpiresIn int64 `json:"expires_in"` +} + +func (s *Service) Send(ctx context.Context, in SendCodeInput) (*SentCode, error) { + if s == nil || s.store == nil { + return nil, errs.ErrAvailable + } + ch, target, ok := smsentity.ParseTarget(in.Target) + if !ok { + return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeySMSTargetInvalid, nil)) + } + switch ch { + case smsentity.ChannelEmail: + return s.sendEmail(ctx, in.Purpose, target, reqctxUserID(ctx)) + case smsentity.ChannelPhone: + return s.sendPhone(ctx, in.Purpose, target, reqctxUserID(ctx)) + default: + return nil, errs.ErrInvalidArgument + } +} + +func (s *Service) sendEmail(ctx context.Context, purpose smsentity.Purpose, email string, userID int64) (*SentCode, error) { + if s == nil || s.store == nil { + return nil, errs.ErrAvailable + } + email = userentity.NormalizeEmail(email) + if email == "" { + return nil, errs.ErrInvalidArgument + } + if err := s.guardPurpose(ctx, purpose, smsentity.ChannelEmail, email, userID); err != nil { + return nil, err + } + if skipSendEmail(email) { + return &SentCode{ExpiresIn: CodeTTLSeconds}, nil + } + key := storeKey(smsentity.ChannelEmail, purpose, email) + ok, err := s.store.SetCooldown(ctx, key, defaultCodeCooldownSeconds) + if err != nil { + return nil, err + } + if !ok { + return nil, errs.ErrRateLimited + } + code, err := randomDigits(6) + if err != nil { + return nil, errs.ErrInner.WithError("sms-code", err) + } + if s.email != nil { + lang := reqctx.Language(ctx) + if err := s.email.SendOTP(ctx, email, purpose, code, lang); err != nil { + _ = s.store.Del(ctx, key) + return nil, err + } + } + if err := s.store.Put(ctx, key, code, defaultCodeTTLSeconds); err != nil { + return nil, err + } + return &SentCode{ExpiresIn: CodeTTLSeconds}, nil +} + +func (s *Service) sendPhone(ctx context.Context, purpose smsentity.Purpose, phone string, userID int64) (*SentCode, error) { + if s == nil || s.store == nil { + return nil, errs.ErrAvailable + } + phone = smsentity.NormalizePhone(phone) + if phone == "" { + return nil, errs.ErrInvalidArgument + } + if err := s.guardPurpose(ctx, purpose, smsentity.ChannelPhone, phone, userID); err != nil { + return nil, err + } + if skipSendPhone(phone) { + return &SentCode{ExpiresIn: CodeTTLSeconds}, nil + } + key := storeKey(smsentity.ChannelPhone, purpose, phone) + ok, err := s.store.SetCooldown(ctx, key, defaultCodeCooldownSeconds) + if err != nil { + return nil, err + } + if !ok { + return nil, errs.ErrRateLimited + } + code, err := randomDigits(6) + if err != nil { + return nil, errs.ErrInner.WithError("sms-code", err) + } + if err := s.dispatchPhone(ctx, phone, purpose, code); err != nil { + _ = s.store.Del(ctx, key) + return nil, err + } + if err := s.store.Put(ctx, key, code, defaultCodeTTLSeconds); err != nil { + return nil, err + } + return &SentCode{ExpiresIn: CodeTTLSeconds}, nil +} + +func (s *Service) dispatchPhone(ctx context.Context, phone string, purpose smsentity.Purpose, code string) error { + if len(s.phone) == 0 { + return errs.ErrAvailable + } + carrier := smsentity.DetectCarrier(phone) + for _, p := range s.phone { + if p == nil { + continue + } + if p.Carrier() == carrier { + return p.SendOTP(ctx, phone, purpose, code) + } + } + for _, p := range s.phone { + if p == nil { + continue + } + if p.Carrier() == smsentity.CarrierMobile { + return p.SendOTP(ctx, phone, purpose, code) + } + } + return errs.ErrAvailable +} + +func (s *Service) guardPurpose(ctx context.Context, purpose smsentity.Purpose, ch smsentity.Channel, target string, userID int64) error { + if !purpose.Valid() { + return errs.ErrInvalidArgument + } + if purpose != smsentity.PurposeAccountDelete { + return nil + } + if userID <= 0 { + return errs.ErrUnauthorized + } + if s.accounts == nil { + return errs.ErrAvailable + } + bound, err := s.accounts.EmailByUserID(ctx, userID) + if err != nil { + return err + } + bound = userentity.NormalizeEmail(bound) + if bound == "" { + return errs.ErrForbidden + } + if ch == smsentity.ChannelEmail { + if userentity.NormalizeEmail(target) != bound { + return errs.ErrForbidden + } + return nil + } + _ = strings.TrimSpace(target) + return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeySMSDeletePhoneUnsupported, nil)) +} + +func reqctxUserID(ctx context.Context) int64 { + rc := reqctx.From(ctx) + if rc == nil { + return 0 + } + return rc.UserID +} diff --git a/internal/module/sms/logic/send_more_test.go b/internal/module/sms/logic/send_more_test.go new file mode 100644 index 0000000..fdffcce --- /dev/null +++ b/internal/module/sms/logic/send_more_test.go @@ -0,0 +1,110 @@ +package logic + +import ( + "context" + "errors" + "testing" + + smsentity "spark-server/internal/module/sms/entity" + smsmodel "spark-server/internal/module/sms/model" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/reqctx" +) + +type stubEmail struct { + n int +} + +func (s *stubEmail) SendOTP(context.Context, string, smsentity.Purpose, string, string) error { + s.n++ + return nil +} + +type stubPhone struct { + carrier smsentity.Carrier + n int +} + +func (s *stubPhone) Carrier() smsentity.Carrier { return s.carrier } + +func (s *stubPhone) SendOTP(context.Context, string, smsentity.Purpose, string) error { + s.n++ + return nil +} + +type stubAccounts struct { + email string +} + +func (s stubAccounts) EmailByUserID(context.Context, int64) (string, error) { + return s.email, nil +} + +func TestSendUnifiedEmail(t *testing.T) { + store := smsmodel.NewMemoryStore() + mail := &stubEmail{} + svc := NewService(store, mail, nil, nil) + out, err := svc.Send(t.Context(), SendCodeInput{ + Purpose: smsentity.PurposeLogin, + Target: "ada@example.com", + }) + if err != nil || out == nil || mail.n != 1 { + t.Fatalf("send %+v %v mail=%d", out, err, mail.n) + } +} + +func TestSendPhoneDispatchesByCarrier(t *testing.T) { + store := smsmodel.NewMemoryStore() + mobile := &stubPhone{carrier: smsentity.CarrierMobile} + unicom := &stubPhone{carrier: smsentity.CarrierUnicom} + svc := NewService(store, nil, []PhoneCarrierSender{mobile, unicom}, nil) + _, err := svc.Send(t.Context(), SendCodeInput{ + Purpose: smsentity.PurposeLogin, + Target: "13812345678", + }) + if err != nil || mobile.n != 1 || unicom.n != 0 { + t.Fatalf("mobile %v mobile=%d unicom=%d", err, mobile.n, unicom.n) + } + _, err = svc.Send(t.Context(), SendCodeInput{ + Purpose: smsentity.PurposeLogin, + Target: "13012345678", + }) + if err != nil || unicom.n != 1 { + t.Fatalf("unicom %v unicom=%d", err, unicom.n) + } +} + +func TestGuardAccountDeletePurpose(t *testing.T) { + store := smsmodel.NewMemoryStore() + svc := NewService(store, &stubEmail{}, nil, stubAccounts{email: "me@example.com"}) + ctx := reqctx.With(t.Context(), &reqctx.RequestContext{UserID: 9}) + _, err := svc.Send(ctx, SendCodeInput{ + Purpose: smsentity.PurposeAccountDelete, + Target: "other@example.com", + }) + if !errors.Is(err, errs.ErrForbidden) { + t.Fatalf("wrong email %v", err) + } + _, err = svc.Send(ctx, SendCodeInput{ + Purpose: smsentity.PurposeAccountDelete, + Target: "me@example.com", + }) + if err != nil { + t.Fatal(err) + } +} + +func TestClearTargetRemovesCode(t *testing.T) { + store := smsmodel.NewMemoryStore() + svc := NewService(store, nil, nil, nil) + ctx := t.Context() + email := "ada@example.com" + key := storeKey(smsentity.ChannelEmail, smsentity.PurposeLogin, email) + if err := store.Put(ctx, key, "123456", 60); err != nil { + t.Fatal(err) + } + svc.ClearTarget(ctx, smsentity.ChannelEmail, email) + if err := svc.CheckAndConsume(ctx, smsentity.ChannelEmail, smsentity.PurposeLogin, email, "123456"); !errors.Is(err, errs.ErrVerifyCodeInvalid) { + t.Fatalf("still there %v", err) + } +} diff --git a/internal/module/sms/logic/send_test.go b/internal/module/sms/logic/send_test.go new file mode 100644 index 0000000..88135f1 --- /dev/null +++ b/internal/module/sms/logic/send_test.go @@ -0,0 +1,24 @@ +package logic + +import ( + "errors" + "testing" + + smsentity "spark-server/internal/module/sms/entity" + smsmodel "spark-server/internal/module/sms/model" + "spark-server/internal/pkg/errs" +) + +func TestSendCodeCooldown(t *testing.T) { + store := smsmodel.NewMemoryStore() + svc := NewService(store, nil, nil, nil) + if _, err := svc.Send(t.Context(), SendCodeInput{Purpose: smsentity.PurposeLogin, Target: "test06@spark.com"}); err != nil { + t.Fatal(err) + } + if _, err := svc.Send(t.Context(), SendCodeInput{Purpose: smsentity.PurposeLogin, Target: "ada@example.com"}); err != nil { + t.Fatal(err) + } + if _, err := svc.Send(t.Context(), SendCodeInput{Purpose: smsentity.PurposeLogin, Target: "ada@example.com"}); !errors.Is(err, errs.ErrRateLimited) { + t.Fatalf("cooldown %v", err) + } +} diff --git a/internal/module/sms/logic/service.go b/internal/module/sms/logic/service.go new file mode 100644 index 0000000..88e562b --- /dev/null +++ b/internal/module/sms/logic/service.go @@ -0,0 +1,69 @@ +package logic + +import ( + "context" + "crypto/rand" + "encoding/binary" + "fmt" + + smsentity "spark-server/internal/module/sms/entity" + userentity "spark-server/internal/module/user/entity" +) + +const ( + defaultCodeTTLSeconds = 300 + defaultCodeCooldownSeconds = 60 +) + +// CodeTTLSeconds is the OTP lifetime returned to clients. +const CodeTTLSeconds = defaultCodeTTLSeconds + +// AccountEmail resolves the bound mailbox for an authenticated user (account delete OTP). +type AccountEmail interface { + EmailByUserID(ctx context.Context, userID int64) (string, error) +} + +// Service issues and checks OTPs for email and phone. +type Service struct { + store CodeStore + email EmailSender + phone []PhoneCarrierSender + accounts AccountEmail +} + +func NewService(store CodeStore, email EmailSender, phone []PhoneCarrierSender, accounts AccountEmail) *Service { + return &Service{store: store, email: email, phone: phone, accounts: accounts} +} + +func storeKey(ch smsentity.Channel, purpose smsentity.Purpose, target string) string { + return fmt.Sprintf("%d:%d:%s", ch, purpose, target) +} + +func randomDigits(n int) (string, error) { + if n <= 0 { + return "", fmt.Errorf("digits") + } + var buf [8]byte + out := make([]byte, n) + for i := 0; i < n; i++ { + if _, err := rand.Read(buf[:4]); err != nil { + return "", err + } + out[i] = '0' + byte(binary.BigEndian.Uint32(buf[:4])%10) + } + return string(out), nil +} + +func skipSendEmail(email string) bool { + return userentity.IsTestEmail(email) +} + +func skipSendPhone(phone string) bool { + // Dev-friendly test MSISDNs matching spark test convention. + switch phone { + case "+8613800138000", "+8613800138001", "+8613800138002": + return true + default: + return false + } +} diff --git a/internal/module/sms/logic/store.go b/internal/module/sms/logic/store.go new file mode 100644 index 0000000..8c15e6e --- /dev/null +++ b/internal/module/sms/logic/store.go @@ -0,0 +1,11 @@ +package logic + +import "context" + +// CodeStore persists OTP values and per-target send cooldowns. +type CodeStore interface { + Put(ctx context.Context, key, code string, ttlSeconds int) error + Get(ctx context.Context, key string) (string, error) + Del(ctx context.Context, key string) error + SetCooldown(ctx context.Context, key string, ttlSeconds int) (bool, error) +} diff --git a/internal/module/sms/logic/verify.go b/internal/module/sms/logic/verify.go new file mode 100644 index 0000000..0c8d41b --- /dev/null +++ b/internal/module/sms/logic/verify.go @@ -0,0 +1,65 @@ +package logic + +import ( + "context" + "crypto/subtle" + + smsentity "spark-server/internal/module/sms/entity" + userentity "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/errs" +) + +// CheckAndConsume validates an OTP and deletes it on success. +func (s *Service) CheckAndConsume(ctx context.Context, ch smsentity.Channel, purpose smsentity.Purpose, target, code string) error { + if s == nil || s.store == nil { + return errs.ErrAvailable + } + if !ch.Valid() || !purpose.Valid() { + return errs.ErrInvalidArgument + } + target = normalizeTarget(ch, target) + if target == "" || code == "" { + return errs.ErrVerifyCodeInvalid + } + if ch == smsentity.ChannelEmail && userentity.IsTestEmail(target) { + return nil + } + if ch == smsentity.ChannelPhone && skipSendPhone(target) { + return nil + } + key := storeKey(ch, purpose, target) + want, err := s.store.Get(ctx, key) + if err != nil { + return err + } + if want == "" || subtle.ConstantTimeCompare([]byte(want), []byte(code)) != 1 { + return errs.ErrVerifyCodeInvalid + } + _ = s.store.Del(ctx, key) + return nil +} + +// ClearTarget removes stored OTP and cooldown keys for one target (all purposes on that channel). +func (s *Service) ClearTarget(ctx context.Context, ch smsentity.Channel, target string) { + if s == nil || s.store == nil { + return + } + target = normalizeTarget(ch, target) + if target == "" { + return + } + for _, p := range []smsentity.Purpose{smsentity.PurposeLogin, smsentity.PurposeAccountDelete} { + _ = s.store.Del(ctx, storeKey(ch, p, target)) + } +} + +func normalizeTarget(ch smsentity.Channel, target string) string { + switch ch { + case smsentity.ChannelEmail: + return userentity.NormalizeEmail(target) + case smsentity.ChannelPhone: + return smsentity.NormalizePhone(target) + default: + return "" + } +} diff --git a/internal/module/sms/logic/verify_test.go b/internal/module/sms/logic/verify_test.go new file mode 100644 index 0000000..0c2d81b --- /dev/null +++ b/internal/module/sms/logic/verify_test.go @@ -0,0 +1,34 @@ +package logic + +import ( + "errors" + "testing" + + smsentity "spark-server/internal/module/sms/entity" + smsmodel "spark-server/internal/module/sms/model" + "spark-server/internal/pkg/errs" +) + +func TestCheckAndConsumeInvalidUnified(t *testing.T) { + store := smsmodel.NewMemoryStore() + svc := NewService(store, nil, nil, nil) + ctx := t.Context() + email := "ada@example.com" + key := storeKey(smsentity.ChannelEmail, smsentity.PurposeLogin, email) + + if err := svc.CheckAndConsume(ctx, smsentity.ChannelEmail, smsentity.PurposeLogin, email, "000000"); !errors.Is(err, errs.ErrVerifyCodeInvalid) { + t.Fatalf("missing: %v", err) + } + if err := store.Put(ctx, key, "123456", 60); err != nil { + t.Fatal(err) + } + if err := svc.CheckAndConsume(ctx, smsentity.ChannelEmail, smsentity.PurposeLogin, email, "999999"); !errors.Is(err, errs.ErrVerifyCodeInvalid) { + t.Fatalf("wrong: %v", err) + } + if err := svc.CheckAndConsume(ctx, smsentity.ChannelEmail, smsentity.PurposeLogin, email, "123456"); err != nil { + t.Fatal(err) + } + if err := svc.CheckAndConsume(ctx, smsentity.ChannelEmail, smsentity.PurposeLogin, email, "123456"); !errors.Is(err, errs.ErrVerifyCodeInvalid) { + t.Fatalf("used: %v", err) + } +} diff --git a/internal/module/sms/model/memory_store.go b/internal/module/sms/model/memory_store.go new file mode 100644 index 0000000..2b90901 --- /dev/null +++ b/internal/module/sms/model/memory_store.go @@ -0,0 +1,58 @@ +package model + +import ( + "context" + "sync" + "time" +) + +type MemoryStore struct { + mu sync.Mutex + codes map[string]codeEntry + cooldowns map[string]time.Time +} + +type codeEntry struct { + code string + exp time.Time +} + +func NewMemoryStore() *MemoryStore { + return &MemoryStore{codes: make(map[string]codeEntry), cooldowns: make(map[string]time.Time)} +} + +func (m *MemoryStore) Put(_ context.Context, key, code string, ttlSeconds int) error { + m.mu.Lock() + defer m.mu.Unlock() + m.codes[key] = codeEntry{code: code, exp: time.Now().Add(time.Duration(ttlSeconds) * time.Second)} + return nil +} + +func (m *MemoryStore) Get(_ context.Context, key string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + e, ok := m.codes[key] + if !ok || time.Now().After(e.exp) { + delete(m.codes, key) + return "", nil + } + return e.code, nil +} + +func (m *MemoryStore) Del(_ context.Context, key string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.codes, key) + delete(m.cooldowns, key) + return nil +} + +func (m *MemoryStore) SetCooldown(_ context.Context, key string, ttlSeconds int) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + if exp, ok := m.cooldowns[key]; ok && time.Now().Before(exp) { + return false, nil + } + m.cooldowns[key] = time.Now().Add(time.Duration(ttlSeconds) * time.Second) + return true, nil +} diff --git a/internal/module/sms/model/redis_store.go b/internal/module/sms/model/redis_store.go new file mode 100644 index 0000000..812cac1 --- /dev/null +++ b/internal/module/sms/model/redis_store.go @@ -0,0 +1,69 @@ +package model + +import ( + "context" + "time" + + "github.com/redis/go-redis/v9" + + "spark-server/internal/pkg/errs" +) + +const ( + codeKeyPrefix = "sms:code:" + codeCDPrefix = "sms:cd:" +) + +type RedisStore struct { + rdb *redis.Client +} + +func NewRedisStore(rdb *redis.Client) *RedisStore { + return &RedisStore{rdb: rdb} +} + +func (r *RedisStore) Put(ctx context.Context, key, code string, ttlSeconds int) error { + if r == nil || r.rdb == nil { + return errs.ErrAvailable + } + k := codeKeyPrefix + key + if err := r.rdb.Set(ctx, k, code, time.Duration(ttlSeconds)*time.Second).Err(); err != nil { + return errs.ErrAvailable.WithError("sms-code", err) + } + return nil +} + +func (r *RedisStore) Get(ctx context.Context, key string) (string, error) { + if r == nil || r.rdb == nil { + return "", errs.ErrAvailable + } + v, err := r.rdb.Get(ctx, codeKeyPrefix+key).Result() + if err == redis.Nil { + return "", nil + } + if err != nil { + return "", errs.ErrAvailable.WithError("sms-code", err) + } + return v, nil +} + +func (r *RedisStore) Del(ctx context.Context, key string) error { + if r == nil || r.rdb == nil { + return errs.ErrAvailable + } + if err := r.rdb.Del(ctx, codeKeyPrefix+key, codeCDPrefix+key).Err(); err != nil { + return errs.ErrAvailable.WithError("sms-code", err) + } + return nil +} + +func (r *RedisStore) SetCooldown(ctx context.Context, key string, ttlSeconds int) (bool, error) { + if r == nil || r.rdb == nil { + return false, errs.ErrAvailable + } + ok, err := r.rdb.SetNX(ctx, codeCDPrefix+key, "1", time.Duration(ttlSeconds)*time.Second).Result() + if err != nil { + return false, errs.ErrAvailable.WithError("sms-code", err) + } + return ok, nil +} diff --git a/internal/module/square/entity/content.go b/internal/module/square/entity/content.go new file mode 100644 index 0000000..10c9b27 --- /dev/null +++ b/internal/module/square/entity/content.go @@ -0,0 +1,157 @@ +package entity + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + uploadentity "spark-server/internal/module/upload/entity" +) + +// Content is persisted JSON for images/video. +type Content struct { + Images []*Image `json:"images,omitempty"` + Video *Video `json:"video,omitempty"` +} + +// Image matches chat/moment image JSON field names. +type Image struct { + Key string `json:"key"` + Mime string `json:"mime,omitempty"` + Width int `json:"w,omitempty"` + Height int `json:"h,omitempty"` + Size int64 `json:"size,omitempty"` + ThumbKey string `json:"thumb_key,omitempty"` + OrigKey string `json:"orig_key,omitempty"` +} + +// Video matches chat/moment video JSON field names. +type Video struct { + Key string `json:"key"` + Mime string `json:"mime,omitempty"` + Width int `json:"w,omitempty"` + Height int `json:"h,omitempty"` + DurationMS int `json:"duration_ms,omitempty"` + Size int64 `json:"size,omitempty"` + CoverKey string `json:"cover_key,omitempty"` +} + +func (c *Content) Marshal() ([]byte, error) { + if c == nil { + c = &Content{} + } + if err := c.Validate(); err != nil { + return nil, err + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(c); err != nil { + return nil, fmt.Errorf("square content: %w", err) + } + b := bytes.TrimSuffix(buf.Bytes(), []byte{'\n'}) + if len(b) > MaxContentBytes { + return nil, fmt.Errorf("square content: too large") + } + return b, nil +} + +func UnmarshalContent(raw []byte) (*Content, error) { + if len(raw) == 0 { + return &Content{}, nil + } + var c Content + if err := json.Unmarshal(raw, &c); err != nil { + return nil, fmt.Errorf("square content: %w", err) + } + if err := c.Validate(); err != nil { + return nil, err + } + return &c, nil +} + +func (c *Content) Validate() error { + if c == nil { + return nil + } + if len(c.Images) > 0 && c.Video != nil { + return fmt.Errorf("square content: image and video") + } + if len(c.Images) > MaxImages { + return fmt.Errorf("square content: too many images") + } + for _, img := range c.Images { + if err := img.validate(); err != nil { + return err + } + } + if c.Video != nil { + return c.Video.validate() + } + return nil +} + +func (c *Content) Kind() Kind { + if c != nil && c.Video != nil { + return KindVideo + } + if c != nil && len(c.Images) > 0 { + return KindImage + } + return KindText +} + +func (c *Content) ObjectKeys() []string { + if c == nil { + return nil + } + out := make([]string, 0, 12) + for _, img := range c.Images { + out = appendKeys(out, img.Key, img.ThumbKey, img.OrigKey) + } + if c.Video != nil { + out = appendKeys(out, c.Video.Key, c.Video.CoverKey) + } + return out +} + +func (img *Image) validate() error { + if img == nil || !squareKey(img.Key, uploadentity.PrefixSquareImage) { + return fmt.Errorf("square content: image key") + } + if img.ThumbKey != "" && !squareKey(img.ThumbKey, uploadentity.PrefixSquareImage) { + return fmt.Errorf("square content: thumb") + } + if img.OrigKey != "" && !squareKey(img.OrigKey, uploadentity.PrefixSquareImage) { + return fmt.Errorf("square content: orig") + } + return nil +} + +func (v *Video) validate() error { + if v == nil || !squareKey(v.Key, uploadentity.PrefixSquareVideo) { + return fmt.Errorf("square content: video key") + } + if v.DurationMS <= 0 || v.DurationMS > MaxVideoMS { + return fmt.Errorf("square content: duration") + } + if v.CoverKey != "" && !squareKey(v.CoverKey, uploadentity.PrefixSquareImage) { + return fmt.Errorf("square content: cover") + } + return nil +} + +func squareKey(key, prefix string) bool { + key = strings.TrimSpace(key) + return uploadentity.ValidObjectKey(key) && strings.HasPrefix(key, prefix) +} + +func appendKeys(dst []string, keys ...string) []string { + for _, k := range keys { + if k != "" { + dst = append(dst, k) + } + } + return dst +} diff --git a/internal/module/square/entity/kind.go b/internal/module/square/entity/kind.go new file mode 100644 index 0000000..a694e44 --- /dev/null +++ b/internal/module/square/entity/kind.go @@ -0,0 +1,83 @@ +package entity + +// Kind is square_posts.kind. +type Kind int16 + +const ( + KindText Kind = 1 + KindImage Kind = 2 + KindVideo Kind = 3 +) + +func (k Kind) Valid() bool { + return k == KindText || k == KindImage || k == KindVideo +} + +// Visibility matches moment_posts.visibility values. +type Visibility int16 + +const ( + VisibilityPublic Visibility = 0 + VisibilityPrivate Visibility = 1 + VisibilityInclude Visibility = 2 + VisibilityExclude Visibility = 3 +) + +func (v Visibility) Valid() bool { + return v >= VisibilityPublic && v <= VisibilityExclude +} + +func (v Visibility) NeedsAudience() bool { + return v == VisibilityInclude || v == VisibilityExclude +} + +// AuditStatus is square_posts.audit_status. +type AuditStatus int16 + +const ( + AuditPending AuditStatus = 0 + AuditApproved AuditStatus = 1 + AuditRejected AuditStatus = 2 +) + +// Status is post/comment row status. +type Status int16 + +const ( + StatusNormal Status = 0 + StatusDeleted Status = 1 +) + +// NoticeKind is square_notices.kind. +type NoticeKind int16 + +const ( + NoticeLike NoticeKind = 1 + NoticeComment NoticeKind = 2 + NoticeReply NoticeKind = 3 +) + +const ( + MaxTextRunes = 1000 + MaxImages = 9 + MaxAudience = 200 + MaxCommentRunes = 500 + MaxVideoMS = 30_000 + ListDefault = 20 + ListMax = 50 + Overfetch = 50 + OverfetchRounds = 3 + LikePreviewMax = 10 + CommentPreviewMax = 20 + MaxContentBytes = 16 << 10 +) + +func ClampLimit(limit int) int { + if limit <= 0 { + return ListDefault + } + if limit > ListMax { + return ListMax + } + return limit +} diff --git a/internal/module/square/entity/post.go b/internal/module/square/entity/post.go new file mode 100644 index 0000000..e77e982 --- /dev/null +++ b/internal/module/square/entity/post.go @@ -0,0 +1,71 @@ +package entity + +// Post is one square entry. +type Post struct { + ID int64 + UserID int64 + Kind Kind + Text string + Content []byte + POI POI + Visibility Visibility + AuditStatus AuditStatus + Status Status + LikeCount int + CommentCount int + CreatedAtMS int64 +} + +// POI is an optional location pin. +type POI struct { + Lat float64 + Lng float64 + Name string + Address string +} + +func (p POI) Set() bool { + return p.Name != "" || p.Address != "" || p.Lat != 0 || p.Lng != 0 +} + +// Like is one (post, user) edge. +type Like struct { + PostID int64 + UserID int64 + CreatedAtMS int64 +} + +// Comment is one square comment or reply. +type Comment struct { + ID int64 + PostID int64 + UserID int64 + ReplyToID int64 + ReplyToUID int64 + Text string + Status Status + CreatedAtMS int64 +} + +// Notice is a like/comment notification. +type Notice struct { + ID int64 + ToUserID int64 + FromUserID int64 + Kind NoticeKind + PostID int64 + CommentID int64 + ReadAtMS int64 + CreatedAtMS int64 +} + +// NoticeState is per-user read watermark for square notices. +type NoticeState struct { + UserID int64 + LastReadNoticeID int64 + UpdatedAtMS int64 +} + +func DefaultNoticeState(userID int64) *NoticeState { + return &NoticeState{UserID: userID} +} diff --git a/internal/module/square/logic/album.go b/internal/module/square/logic/album.go new file mode 100644 index 0000000..14e4b51 --- /dev/null +++ b/internal/module/square/logic/album.go @@ -0,0 +1,57 @@ +package logic + +import ( + "context" + + "spark-server/internal/module/square/entity" + "spark-server/internal/pkg/errs" +) + +// UserPosts lists posts by owner visible to viewer (profile square tab). +func (s *Service) UserPosts(ctx context.Context, viewerID, ownerID, cursor int64, limit int) (*ListResult, error) { + if !s.ready() || viewerID <= 0 { + return nil, errs.ErrUnauthorized + } + if ownerID <= 0 { + return nil, errs.ErrInvalidArgument + } + if ownerID != viewerID { + blocked, err := s.friends.BlockedEither(ctx, viewerID, ownerID) + if err != nil { + return nil, err + } + if blocked { + return nil, errs.ErrNotFound + } + } + limit = entity.ClampLimit(limit) + picked := make([]*entity.Post, 0, limit) + scan := cursor + for range entity.OverfetchRounds { + batch, err := s.repo.ListByAuthor(ctx, ownerID, scan, entity.Overfetch) + if err != nil { + return nil, err + } + if len(batch) == 0 { + break + } + vis, err := s.visFor(ctx, viewerID, batch) + if err != nil { + return nil, err + } + for _, p := range batch { + scan = p.ID + if !vis.canSee(p) { + continue + } + picked = append(picked, p) + if len(picked) >= limit { + break + } + } + if len(picked) >= limit || len(batch) < entity.Overfetch { + break + } + } + return s.listResult(ctx, viewerID, picked, limit, false) +} diff --git a/internal/module/square/logic/attach.go b/internal/module/square/logic/attach.go new file mode 100644 index 0000000..2788b06 --- /dev/null +++ b/internal/module/square/logic/attach.go @@ -0,0 +1,155 @@ +package logic + +import ( + "context" + + "spark-server/internal/module/square/entity" + userentity "spark-server/internal/module/user/entity" +) + +func (s *Service) attachViews(ctx context.Context, viewer int64, posts []*entity.Post, full bool) ([]*View, error) { + if len(posts) == 0 { + return []*View{}, nil + } + ids := postIDs(posts) + likes, err := s.repo.ListLikes(ctx, ids) + if err != nil { + return nil, err + } + comments, err := s.repo.ListComments(ctx, ids) + if err != nil { + return nil, err + } + uids := collectUIDs(posts, likes, comments, viewer) + users := s.loadUsers(ctx, uids) + remarks := s.loadRemarks(ctx, viewer, uids) + out := make([]*View, 0, len(posts)) + for _, p := range posts { + out = append(out, s.oneView(viewer, p, likes[p.ID], comments[p.ID], users, remarks, full)) + } + return out, nil +} + +func (s *Service) oneView(viewer int64, p *entity.Post, likes []*entity.Like, comments []*entity.Comment, users map[int64]*userentity.User, remarks map[int64]string, full bool) *View { + likeCards, liked, likeN := filterLikes(viewer, likes, users, remarks, full) + commentViews, commentN := filterComments(comments, users, remarks, full) + return &View{ + ID: p.ID, User: cardOf(users[p.UserID], remarks[p.UserID]), Kind: p.Kind, Text: p.Text, + Content: contentJSON(p.Content), POI: poiPtr(p.POI), Visibility: p.Visibility, + LikeCount: likeN, CommentCount: commentN, + Liked: liked, Likes: likeCards, Comments: commentViews, CreatedAt: p.CreatedAtMS, + } +} + +func filterLikes(viewer int64, likes []*entity.Like, users map[int64]*userentity.User, remarks map[int64]string, full bool) ([]UserCard, bool, int) { + out := make([]UserCard, 0) + liked := false + visible := 0 + for _, like := range likes { + if like == nil { + continue + } + if like.UserID == viewer { + liked = true + } + visible++ + if full || len(out) < entity.LikePreviewMax { + out = append(out, cardOf(users[like.UserID], remarks[like.UserID])) + } + } + return out, liked, visible +} + +func filterComments(comments []*entity.Comment, users map[int64]*userentity.User, remarks map[int64]string, full bool) ([]*CommentView, int) { + out := make([]*CommentView, 0) + alive := 0 + for _, c := range comments { + if c == nil { + continue + } + cv := &CommentView{ + ID: c.ID, User: cardOf(users[c.UserID], remarks[c.UserID]), + ReplyToID: c.ReplyToID, ReplyToUID: c.ReplyToUID, Text: c.Text, CreatedAt: c.CreatedAtMS, + } + if c.Status == entity.StatusDeleted { + cv.Deleted = true + cv.Text = "" + } else { + alive++ + } + if full || len(out) < entity.CommentPreviewMax { + out = append(out, cv) + } + } + return out, alive +} + +func postIDs(posts []*entity.Post) []int64 { + out := make([]int64, 0, len(posts)) + for _, p := range posts { + if p != nil { + out = append(out, p.ID) + } + } + return out +} + +func collectUIDs(posts []*entity.Post, likes map[int64][]*entity.Like, comments map[int64][]*entity.Comment, viewer int64) []int64 { + seen := map[int64]struct{}{viewer: {}} + add := func(id int64) { + if id > 0 { + seen[id] = struct{}{} + } + } + for _, p := range posts { + if p != nil { + add(p.UserID) + } + } + for _, rows := range likes { + for _, like := range rows { + if like != nil { + add(like.UserID) + } + } + } + for _, rows := range comments { + for _, c := range rows { + if c != nil { + add(c.UserID) + } + } + } + out := make([]int64, 0, len(seen)) + for id := range seen { + out = append(out, id) + } + return out +} + +func (s *Service) loadUsers(ctx context.Context, ids []int64) map[int64]*userentity.User { + out := make(map[int64]*userentity.User, len(ids)) + for _, id := range ids { + u, err := s.users.Get(ctx, id) + if err != nil { + continue + } + out[id] = u + } + return out +} + +func (s *Service) loadRemarks(ctx context.Context, viewer int64, ids []int64) map[int64]string { + out := make(map[int64]string) + for _, id := range ids { + if id == viewer { + continue + } + rel, err := s.friends.Relation(ctx, viewer, id) + if err != nil || rel == nil { + continue + } + out[id] = rel.Remark + } + return out +} diff --git a/internal/module/square/logic/comment.go b/internal/module/square/logic/comment.go new file mode 100644 index 0000000..5124cb9 --- /dev/null +++ b/internal/module/square/logic/comment.go @@ -0,0 +1,117 @@ +package logic + +import ( + "context" + "strings" + "time" + "unicode/utf8" + + "spark-server/internal/module/square/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/pkg/validate" +) + +type CommentInput struct { + Text string `json:"text" validate:"required,min=1,max=500" label:"field.square_comment" errmsg:"required:validate.square.comment_required;min:validate.square.comment_invalid;max:validate.square.comment_invalid"` + ReplyToID int64 `json:"reply_to_id" validate:"omitempty,gt=0" label:"field.square_reply"` +} + +func (s *Service) Comment(ctx context.Context, userID, postID int64, in *CommentInput) (*View, error) { + post, err := s.visiblePost(ctx, userID, postID) + if err != nil { + return nil, err + } + if in == nil { + return nil, errs.ErrInvalidArgument + } + if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { + return nil, err + } + if !s.allowInteract(ctx, userID) { + return nil, errs.ErrRateLimited + } + text := strings.TrimSpace(in.Text) + if text == "" || utf8.RuneCountInString(text) > entity.MaxCommentRunes { + return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeySquareCommentInvalid, nil)) + } + parent, err := s.loadParent(ctx, postID, in.ReplyToID) + if err != nil { + return nil, err + } + id, err := s.ids.NextID() + if err != nil { + return nil, err + } + now := time.Now().UnixMilli() + row := &entity.Comment{ + ID: id, PostID: postID, UserID: userID, Text: text, Status: entity.StatusNormal, CreatedAtMS: now, + } + if parent != nil { + row.ReplyToID = parent.ID + row.ReplyToUID = parent.UserID + } + if err := s.repo.AddComment(ctx, row); err != nil { + return nil, err + } + _ = s.notify(ctx, commentNotices(post, row, userID)) + return s.Get(ctx, userID, postID) +} + +func (s *Service) DeleteComment(ctx context.Context, userID, postID, commentID int64) (*View, error) { + post, err := s.visiblePost(ctx, userID, postID) + if err != nil { + return nil, err + } + c, err := s.repo.GetComment(ctx, commentID) + if err != nil { + return nil, err + } + if c.PostID != postID || c.Status == entity.StatusDeleted { + return nil, errs.ErrNotFound + } + if c.UserID != userID && post.UserID != userID { + return nil, errs.ErrForbidden + } + if err := s.repo.SoftDeleteComment(ctx, commentID); err != nil { + return nil, err + } + return s.Get(ctx, userID, postID) +} + +func (s *Service) loadParent(ctx context.Context, postID, replyToID int64) (*entity.Comment, error) { + if replyToID <= 0 { + return nil, nil + } + parent, err := s.repo.GetComment(ctx, replyToID) + if err != nil { + return nil, err + } + if parent.PostID != postID || parent.Status != entity.StatusNormal { + return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeySquareReplyInvalid, nil)) + } + return parent, nil +} + +func commentNotices(post *entity.Post, c *entity.Comment, from int64) []*entity.Notice { + seen := map[int64]struct{}{} + out := make([]*entity.Notice, 0, 2) + add := func(to int64, kind entity.NoticeKind) { + if to <= 0 || to == from { + return + } + if _, ok := seen[to]; ok { + return + } + seen[to] = struct{}{} + out = append(out, &entity.Notice{ + ToUserID: to, FromUserID: from, Kind: kind, PostID: post.ID, CommentID: c.ID, + }) + } + if c.ReplyToUID > 0 { + add(c.ReplyToUID, entity.NoticeReply) + } + add(post.UserID, entity.NoticeComment) + return out +} diff --git a/internal/module/square/logic/delete.go b/internal/module/square/logic/delete.go new file mode 100644 index 0000000..ee5c268 --- /dev/null +++ b/internal/module/square/logic/delete.go @@ -0,0 +1,36 @@ +package logic + +import ( + "context" + + "spark-server/internal/module/square/entity" + "spark-server/internal/pkg/errs" +) + +func (s *Service) Delete(ctx context.Context, userID, postID int64) error { + if !s.ready() || userID <= 0 { + return errs.ErrUnauthorized + } + if postID <= 0 { + return errs.ErrInvalidArgument + } + post, err := s.repo.SoftDeletePost(ctx, userID, postID) + if err != nil { + return err + } + s.deleteMedia(ctx, post) + return nil +} + +func (s *Service) deleteMedia(ctx context.Context, post *entity.Post) { + if s.objects == nil || post == nil { + return + } + content, err := entity.UnmarshalContent(post.Content) + if err != nil { + return + } + for _, key := range content.ObjectKeys() { + _ = s.objects.Delete(ctx, key) + } +} diff --git a/internal/module/square/logic/feed.go b/internal/module/square/logic/feed.go new file mode 100644 index 0000000..9bb846a --- /dev/null +++ b/internal/module/square/logic/feed.go @@ -0,0 +1,57 @@ +package logic + +import ( + "context" + + "spark-server/internal/module/square/entity" + "spark-server/internal/pkg/errs" +) + +func (s *Service) Feed(ctx context.Context, viewerID, cursor int64, limit int) (*ListResult, error) { + if !s.ready() || viewerID <= 0 { + return nil, errs.ErrUnauthorized + } + limit = entity.ClampLimit(limit) + picked := make([]*entity.Post, 0, limit) + scan := cursor + for range entity.OverfetchRounds { + batch, err := s.repo.ListPublicFeed(ctx, scan, entity.Overfetch) + if err != nil { + return nil, err + } + if len(batch) == 0 { + break + } + vis, err := s.visFor(ctx, viewerID, batch) + if err != nil { + return nil, err + } + for _, p := range batch { + scan = p.ID + if !vis.canSee(p) { + continue + } + picked = append(picked, p) + if len(picked) >= limit { + break + } + } + if len(picked) >= limit || len(batch) < entity.Overfetch { + break + } + } + return s.listResult(ctx, viewerID, picked, limit, false) +} + +func (s *Service) listResult(ctx context.Context, viewer int64, posts []*entity.Post, limit int, full bool) (*ListResult, error) { + views, err := s.attachViews(ctx, viewer, posts, full) + if err != nil { + return nil, err + } + next := int64(0) + if len(posts) > 0 { + next = posts[len(posts)-1].ID + } + hasMore := len(posts) >= limit && limit > 0 + return &ListResult{Items: views, NextCursor: next, HasMore: hasMore}, nil +} diff --git a/internal/module/square/logic/like.go b/internal/module/square/logic/like.go new file mode 100644 index 0000000..5f13703 --- /dev/null +++ b/internal/module/square/logic/like.go @@ -0,0 +1,59 @@ +package logic + +import ( + "context" + "errors" + "time" + + "spark-server/internal/module/square/entity" + "spark-server/internal/pkg/errs" +) + +func (s *Service) Like(ctx context.Context, userID, postID int64) (*View, error) { + post, err := s.visiblePost(ctx, userID, postID) + if err != nil { + return nil, err + } + if !s.allowInteract(ctx, userID) { + return nil, errs.ErrRateLimited + } + created, err := s.repo.AddLike(ctx, &entity.Like{PostID: postID, UserID: userID, CreatedAtMS: time.Now().UnixMilli()}) + if err != nil { + return nil, err + } + if created { + _ = s.notify(ctx, []*entity.Notice{s.newNotice(post.UserID, userID, entity.NoticeLike, postID, 0)}) + } + return s.Get(ctx, userID, postID) +} + +func (s *Service) Unlike(ctx context.Context, userID, postID int64) (*View, error) { + if _, err := s.visiblePost(ctx, userID, postID); err != nil { + return nil, err + } + if err := s.repo.RemoveLike(ctx, postID, userID); err != nil && !errors.Is(err, errs.ErrNotFound) { + return nil, err + } + return s.Get(ctx, userID, postID) +} + +func (s *Service) visiblePost(ctx context.Context, viewerID, postID int64) (*entity.Post, error) { + if !s.ready() || viewerID <= 0 { + return nil, errs.ErrUnauthorized + } + if postID <= 0 { + return nil, errs.ErrInvalidArgument + } + post, err := s.repo.GetPost(ctx, postID) + if err != nil { + return nil, err + } + vis, err := s.visFor(ctx, viewerID, []*entity.Post{post}) + if err != nil { + return nil, err + } + if !vis.canSee(post) { + return nil, errs.ErrNotFound + } + return post, nil +} diff --git a/internal/module/square/logic/limit.go b/internal/module/square/logic/limit.go new file mode 100644 index 0000000..8db6118 --- /dev/null +++ b/internal/module/square/logic/limit.go @@ -0,0 +1,44 @@ +package logic + +import ( + "sync" + "time" +) + +type burst struct { + mu sync.Mutex + hits map[string][]time.Time +} + +func newBurst() *burst { + return &burst{hits: make(map[string][]time.Time)} +} + +func (b *burst) allow(key string, window time.Duration, n int) bool { + if b == nil || n < 1 { + return true + } + now := time.Now() + cut := now.Add(-window) + b.mu.Lock() + defer b.mu.Unlock() + kept := b.hits[key][:0] + for _, t := range b.hits[key] { + if t.After(cut) { + kept = append(kept, t) + } + } + if len(kept) >= n { + b.hits[key] = kept + return false + } + b.hits[key] = append(kept, now) + if len(b.hits) > 8192 { + for k, ts := range b.hits { + if len(ts) == 0 || now.Sub(ts[len(ts)-1]) > time.Hour { + delete(b.hits, k) + } + } + } + return true +} diff --git a/internal/module/square/logic/notice.go b/internal/module/square/logic/notice.go new file mode 100644 index 0000000..e28beb4 --- /dev/null +++ b/internal/module/square/logic/notice.go @@ -0,0 +1,124 @@ +package logic + +import ( + "context" + "time" + + "spark-server/internal/module/square/entity" + "spark-server/internal/pkg/errs" +) + +type NoticeView struct { + ID int64 `json:"id"` + Kind entity.NoticeKind `json:"kind"` + From UserCard `json:"from"` + PostID int64 `json:"post_id"` + CommentID int64 `json:"comment_id,omitempty"` + Read bool `json:"read"` + CreatedAt int64 `json:"created_at"` +} + +type NoticeList struct { + Items []*NoticeView `json:"items"` + NextCursor int64 `json:"next_cursor"` +} + +type NoticeUnread struct { + HasNew bool `json:"has_new"` + LatestID int64 `json:"latest_id"` +} + +type NoticeReadInput struct { + Cursor int64 `json:"cursor" validate:"omitempty,gte=0" label:"field.square_notice_cursor"` +} + +func (s *Service) ListNotices(ctx context.Context, userID, cursor int64, limit int) (*NoticeList, error) { + if !s.ready() || userID <= 0 { + return nil, errs.ErrUnauthorized + } + limit = entity.ClampLimit(limit) + rows, err := s.repo.ListNotices(ctx, userID, cursor, limit) + if err != nil { + return nil, err + } + uids := make([]int64, 0, len(rows)) + for _, n := range rows { + uids = append(uids, n.FromUserID) + } + users := s.loadUsers(ctx, uids) + remarks := s.loadRemarks(ctx, userID, uids) + items := make([]*NoticeView, 0, len(rows)) + next := int64(0) + for _, n := range rows { + items = append(items, &NoticeView{ + ID: n.ID, Kind: n.Kind, From: cardOf(users[n.FromUserID], remarks[n.FromUserID]), + PostID: n.PostID, CommentID: n.CommentID, Read: n.ReadAtMS > 0, CreatedAt: n.CreatedAtMS, + }) + next = n.ID + } + return &NoticeList{Items: items, NextCursor: next}, nil +} + +func (s *Service) ReadNotices(ctx context.Context, userID int64, in *NoticeReadInput) error { + if !s.ready() || userID <= 0 { + return errs.ErrUnauthorized + } + cursor := int64(0) + if in != nil { + cursor = in.Cursor + } + if err := s.repo.MarkNoticesRead(ctx, userID, cursor); err != nil { + return err + } + if cursor > 0 { + return s.repo.AdvanceNoticeRead(ctx, userID, cursor) + } + latest, err := s.repo.LatestNoticeID(ctx, userID) + if err != nil { + return err + } + if latest > 0 { + return s.repo.AdvanceNoticeRead(ctx, userID, latest) + } + return nil +} + +func (s *Service) NoticeUnread(ctx context.Context, userID int64) (*NoticeUnread, error) { + if !s.ready() || userID <= 0 { + return nil, errs.ErrUnauthorized + } + st, err := s.repo.GetNoticeState(ctx, userID) + if err != nil { + return nil, err + } + latest, err := s.repo.LatestNoticeID(ctx, userID) + if err != nil { + return nil, err + } + return &NoticeUnread{HasNew: latest > st.LastReadNoticeID, LatestID: latest}, nil +} + +func (s *Service) notify(ctx context.Context, rows []*entity.Notice) error { + out := make([]*entity.Notice, 0, len(rows)) + now := time.Now().UnixMilli() + for _, n := range rows { + if n == nil || n.ToUserID <= 0 || n.ToUserID == n.FromUserID { + continue + } + id, err := s.ids.NextID() + if err != nil { + return err + } + n.ID = id + n.CreatedAtMS = now + out = append(out, n) + } + return s.repo.CreateNotices(ctx, out) +} + +func (s *Service) newNotice(to, from int64, kind entity.NoticeKind, postID, commentID int64) *entity.Notice { + if to <= 0 || to == from { + return nil + } + return &entity.Notice{ToUserID: to, FromUserID: from, Kind: kind, PostID: postID, CommentID: commentID} +} diff --git a/internal/module/square/logic/publish.go b/internal/module/square/logic/publish.go new file mode 100644 index 0000000..069373f --- /dev/null +++ b/internal/module/square/logic/publish.go @@ -0,0 +1,146 @@ +package logic + +import ( + "context" + "strconv" + "strings" + "time" + "unicode/utf8" + + "spark-server/internal/module/square/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/pkg/validate" +) + +type PublishInput struct { + Text string `json:"text" validate:"omitempty,max=1000" label:"field.square_text" errmsg:"max:validate.square.text_invalid"` + Images []*entity.Image `json:"images" validate:"omitempty,max=9,dive"` + Video *entity.Video `json:"video"` + Visibility entity.Visibility `json:"visibility" validate:"omitempty,oneof=0 1 2 3" label:"field.square_visibility" errmsg:"oneof:validate.square.visibility_invalid"` + AudienceUIDs []int64 `json:"audience_uids" validate:"omitempty,max=200,dive,gt=0" label:"field.square_audience"` + POI *entity.POI `json:"poi"` +} + +func (s *Service) Publish(ctx context.Context, userID int64, in *PublishInput) (*View, error) { + if !s.ready() || userID <= 0 { + return nil, errs.ErrUnauthorized + } + if in == nil { + return nil, errs.ErrInvalidArgument + } + if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { + return nil, err + } + if !s.allowPublish(ctx, userID) { + return nil, errs.ErrRateLimited + } + content, kind, err := buildContent(ctx, in) + if err != nil { + return nil, err + } + raw, err := content.Marshal() + if err != nil { + return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeySquareContentInvalid, nil)) + } + if err := s.checkAudience(ctx, userID, in); err != nil { + return nil, err + } + id, err := s.ids.NextID() + if err != nil { + return nil, err + } + now := time.Now() + vis := in.Visibility + if !vis.Valid() { + vis = entity.VisibilityPublic + } + post := &entity.Post{ + ID: id, UserID: userID, Kind: kind, Text: strings.TrimSpace(in.Text), Content: raw, + Visibility: vis, AuditStatus: entity.AuditApproved, Status: entity.StatusNormal, + CreatedAtMS: now.UnixMilli(), + } + if in.POI != nil { + post.POI = *in.POI + } + if err := s.repo.CreatePost(ctx, post, uniqueIDs(in.AudienceUIDs)); err != nil { + return nil, err + } + return s.viewOf(ctx, userID, post, true) +} + +func (s *Service) allowPublish(ctx context.Context, userID int64) bool { + key := strconv.FormatInt(userID, 10) + ":" + reqctx.RealIP(ctx) + return s.burst.allow("p1:"+key, time.Minute, 2) && s.burst.allow("pd:"+key, 24*time.Hour, 20) +} + +func (s *Service) allowInteract(ctx context.Context, userID int64) bool { + key := strconv.FormatInt(userID, 10) + ":" + reqctx.RealIP(ctx) + return s.burst.allow("i:"+key, time.Minute, 60) +} + +func buildContent(ctx context.Context, in *PublishInput) (*entity.Content, entity.Kind, error) { + lang := reqctx.Lang(ctx) + text := strings.TrimSpace(in.Text) + if utf8.RuneCountInString(text) > entity.MaxTextRunes { + return nil, 0, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeySquareTextInvalid, nil)) + } + if len(in.Images) > 0 && in.Video != nil { + return nil, 0, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeySquareMediaConflict, nil)) + } + if text == "" && len(in.Images) == 0 && in.Video == nil { + return nil, 0, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeySquareEmpty, nil)) + } + c := &entity.Content{Images: in.Images, Video: in.Video} + if err := c.Validate(); err != nil { + return nil, 0, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeySquareContentInvalid, nil)) + } + return c, c.Kind(), nil +} + +func (s *Service) checkAudience(ctx context.Context, userID int64, in *PublishInput) error { + lang := reqctx.Lang(ctx) + if !in.Visibility.Valid() { + return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeySquareVisibilityInvalid, nil)) + } + uids := uniqueIDs(in.AudienceUIDs) + if !in.Visibility.NeedsAudience() { + if len(uids) > 0 { + return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeySquareAudienceInvalid, nil)) + } + return nil + } + if len(uids) == 0 || len(uids) > entity.MaxAudience { + return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeySquareAudienceInvalid, nil)) + } + for _, peer := range uids { + if peer == userID { + return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeySquareAudienceInvalid, nil)) + } + ok, err := s.friends.AreFriends(ctx, userID, peer) + if err != nil { + return err + } + if !ok { + return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeySquareAudienceInvalid, nil)) + } + } + return nil +} + +func uniqueIDs(ids []int64) []int64 { + seen := make(map[int64]struct{}, len(ids)) + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} diff --git a/internal/module/square/logic/purge.go b/internal/module/square/logic/purge.go new file mode 100644 index 0000000..90a5655 --- /dev/null +++ b/internal/module/square/logic/purge.go @@ -0,0 +1,22 @@ +package logic + +import ( + "context" +) + +func (s *Service) PurgeUser(ctx context.Context, userID int64) error { + if s == nil || s.repo == nil || userID <= 0 { + return nil + } + posts, err := s.repo.ListAllByAuthor(ctx, userID) + if err != nil { + return err + } + if err := s.repo.PurgeUser(ctx, userID); err != nil { + return err + } + for _, post := range posts { + s.deleteMedia(ctx, post) + } + return nil +} diff --git a/internal/module/square/logic/service.go b/internal/module/square/logic/service.go new file mode 100644 index 0000000..372608d --- /dev/null +++ b/internal/module/square/logic/service.go @@ -0,0 +1,75 @@ +package logic + +import ( + "context" + + friendentity "spark-server/internal/module/friend/entity" + "spark-server/internal/module/square/entity" + userentity "spark-server/internal/module/user/entity" +) + +// Repository persists square data. Model implements it. +type Repository interface { + CreatePost(ctx context.Context, post *entity.Post, audience []int64) error + GetPost(ctx context.Context, id int64) (*entity.Post, error) + ListPublicFeed(ctx context.Context, cursor int64, limit int) ([]*entity.Post, error) + ListByAuthor(ctx context.Context, authorID, cursor int64, limit int) ([]*entity.Post, error) + SoftDeletePost(ctx context.Context, userID, postID int64) (*entity.Post, error) + ListAudience(ctx context.Context, postIDs []int64) (map[int64][]int64, error) + ListLikes(ctx context.Context, postIDs []int64) (map[int64][]*entity.Like, error) + AddLike(ctx context.Context, like *entity.Like) (created bool, err error) + RemoveLike(ctx context.Context, postID, userID int64) error + ListComments(ctx context.Context, postIDs []int64) (map[int64][]*entity.Comment, error) + GetComment(ctx context.Context, id int64) (*entity.Comment, error) + AddComment(ctx context.Context, c *entity.Comment) error + SoftDeleteComment(ctx context.Context, id int64) error + CreateNotices(ctx context.Context, rows []*entity.Notice) error + ListNotices(ctx context.Context, userID, cursor int64, limit int) ([]*entity.Notice, error) + MarkNoticesRead(ctx context.Context, userID, cursor int64) error + GetNoticeState(ctx context.Context, userID int64) (*entity.NoticeState, error) + LatestNoticeID(ctx context.Context, userID int64) (int64, error) + AdvanceNoticeRead(ctx context.Context, userID, noticeID int64) error + ListAllByAuthor(ctx context.Context, authorID int64) ([]*entity.Post, error) + PurgeUser(ctx context.Context, userID int64) error +} + +// Friends is the friend graph used for audience and block checks. +type Friends interface { + AreFriends(ctx context.Context, a, b int64) (bool, error) + ListBlockedPeerIDs(ctx context.Context, userID int64) ([]int64, error) + Relation(ctx context.Context, userID, peerID int64) (*friendentity.Relation, error) + BlockedEither(ctx context.Context, a, b int64) (bool, error) +} + +// Users loads profile cards. +type Users interface { + Get(ctx context.Context, id int64) (*userentity.User, error) +} + +// Objects deletes square media. Nil skips GC. +type Objects interface { + Delete(ctx context.Context, key string) error +} + +// IDGen issues Snowflake ids. +type IDGen interface { + NextID() (int64, error) +} + +// Service is the square use-case layer. +type Service struct { + repo Repository + friends Friends + users Users + objects Objects + ids IDGen + burst *burst +} + +func NewService(repo Repository, friends Friends, users Users, objects Objects, ids IDGen) *Service { + return &Service{repo: repo, friends: friends, users: users, objects: objects, ids: ids, burst: newBurst()} +} + +func (s *Service) ready() bool { + return s != nil && s.repo != nil && s.friends != nil && s.users != nil && s.ids != nil +} diff --git a/internal/module/square/logic/square_test.go b/internal/module/square/logic/square_test.go new file mode 100644 index 0000000..9122193 --- /dev/null +++ b/internal/module/square/logic/square_test.go @@ -0,0 +1,249 @@ +package logic + +import ( + "context" + "errors" + "testing" + + "spark-server/internal/infrastructure/id" + friendlogic "spark-server/internal/module/friend/logic" + friendmodel "spark-server/internal/module/friend/model" + "spark-server/internal/module/square/entity" + squaremodel "spark-server/internal/module/square/model" + userentity "spark-server/internal/module/user/entity" + userlogic "spark-server/internal/module/user/logic" + "spark-server/internal/module/user/logictest" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/reqctx" +) + +type memObj struct { + deleted []string +} + +func (m *memObj) Delete(_ context.Context, key string) error { + m.deleted = append(m.deleted, key) + return nil +} + +func testSquare(t *testing.T) (*Service, *friendlogic.Service, *userlogic.Service) { + t.Helper() + ids, err := id.New(1) + if err != nil { + t.Fatal(err) + } + users := logictest.Users(ids, nil) + friends := friendlogic.NewService(friendmodel.NewMemoryRepo(), users, ids, nil) + svc := NewService(squaremodel.NewMemoryRepo(), friends, users, &memObj{}, ids) + return svc, friends, users +} + +func twoAccounts(t *testing.T, users *userlogic.Service) (*userentity.User, *userentity.User) { + t.Helper() + ctx := t.Context() + a, err := users.AuthenticateEmail(ctx, "test01@spark.com", "1", &userentity.LoginDevice{DeviceID: 1, DeviceType: userentity.DeviceIOS}) + if err != nil { + t.Fatal(err) + } + b, err := users.AuthenticateEmail(ctx, "test02@spark.com", "1", &userentity.LoginDevice{DeviceID: 2, DeviceType: userentity.DeviceWeb}) + if err != nil { + t.Fatal(err) + } + return a, b +} + +func becomeFriends(t *testing.T, friends *friendlogic.Service, a, b *userentity.User) { + t.Helper() + ctx := t.Context() + applied, err := friends.Apply(ctx, a.ID, &friendlogic.ApplyInput{Email: "test02@spark.com"}) + if err != nil { + t.Fatal(err) + } + if _, err := friends.Accept(ctx, b.ID, friendlogic.AcceptInput{RequestID: applied.RequestID}); err != nil { + t.Fatal(err) + } +} + +func ctxUser(t *testing.T, uid int64, ip string) context.Context { + t.Helper() + return reqctx.With(t.Context(), &reqctx.RequestContext{UserID: uid, Language: "zh-CN", RealIP: ip}) +} + +func TestSquarePublishFeedPrivateAndNotice(t *testing.T) { + svc, friends, users := testSquare(t) + a, b := twoAccounts(t, users) + becomeFriends(t, friends, a, b) + ctxA := ctxUser(t, a.ID, "10.0.0.1") + ctxB := ctxUser(t, b.ID, "10.0.0.2") + pub, err := svc.Publish(ctxA, a.ID, &PublishInput{Text: "hello square"}) + if err != nil || pub.Text != "hello square" { + t.Fatalf("%+v %v", pub, err) + } + feed, err := svc.Feed(ctxB, b.ID, 0, 20) + if err != nil || len(feed.Items) != 1 { + t.Fatalf("feed %+v %v", feed, err) + } + priv, err := svc.Publish(ctxA, a.ID, &PublishInput{Text: "secret", Visibility: entity.VisibilityPrivate}) + if err != nil { + t.Fatal(err) + } + feed, err = svc.Feed(ctxB, b.ID, 0, 20) + if err != nil || len(feed.Items) != 1 { + t.Fatalf("private in feed %+v", feed) + } + if _, err := svc.Get(ctxB, b.ID, priv.ID); !errors.Is(err, errs.ErrNotFound) { + t.Fatalf("private get %v", err) + } + liked, err := svc.Like(ctxB, b.ID, pub.ID) + if err != nil || !liked.Liked { + t.Fatal(liked, err) + } + notices, err := svc.ListNotices(ctxA, a.ID, 0, 20) + if err != nil || len(notices.Items) != 1 || notices.Items[0].Kind != entity.NoticeLike { + t.Fatalf("notice %+v %v", notices, err) + } + unread, err := svc.NoticeUnread(ctxA, a.ID) + if err != nil || !unread.HasNew { + t.Fatalf("unread %+v", unread) + } +} + +func TestSquareExcludeIncludeAndUserPosts(t *testing.T) { + svc, friends, users := testSquare(t) + a, b := twoAccounts(t, users) + becomeFriends(t, friends, a, b) + ctx := t.Context() + c, err := users.AuthenticateEmail(ctx, "test03@spark.com", "1", &userentity.LoginDevice{DeviceID: 3, DeviceType: userentity.DeviceIOS}) + if err != nil { + t.Fatal(err) + } + ctxA := ctxUser(t, a.ID, "10.0.0.1") + ctxB := ctxUser(t, b.ID, "10.0.0.2") + ctxC := ctxUser(t, c.ID, "10.0.0.3") + excl, err := svc.Publish(ctxA, a.ID, &PublishInput{ + Text: "hide from b", Visibility: entity.VisibilityExclude, AudienceUIDs: []int64{b.ID}, + }) + if err != nil { + t.Fatal(err) + } + feedB, err := svc.Feed(ctxB, b.ID, 0, 20) + if err != nil { + t.Fatal(err) + } + for _, item := range feedB.Items { + if item.ID == excl.ID { + t.Fatal("excluded user still sees post in feed") + } + } + feedC, err := svc.Feed(ctxC, c.ID, 0, 20) + if err != nil || len(feedC.Items) != 1 { + t.Fatalf("stranger feed %+v %v", feedC, err) + } + incl, err := svc.Publish(ctxA, a.ID, &PublishInput{ + Text: "only b", Visibility: entity.VisibilityInclude, AudienceUIDs: []int64{b.ID}, + }) + if err != nil { + t.Fatal(err) + } + feedC, err = svc.Feed(ctxC, c.ID, 0, 20) + if err != nil { + t.Fatal(err) + } + for _, item := range feedC.Items { + if item.ID == incl.ID { + t.Fatal("include post leaked to stranger feed") + } + } + album, err := svc.UserPosts(ctxB, b.ID, a.ID, 0, 20) + if err != nil { + t.Fatal(err) + } + var sawIncl bool + for _, item := range album.Items { + if item.ID == excl.ID { + t.Fatal("excluded user sees post on profile") + } + if item.ID == incl.ID { + sawIncl = true + } + } + if !sawIncl { + t.Fatalf("include missing from album %+v", album) + } + if _, err := svc.Get(ctxB, b.ID, incl.ID); err != nil { + t.Fatalf("include get %v", err) + } +} + +func TestSquareUnlikeCommentDeleteAndPurge(t *testing.T) { + svc, friends, users := testSquare(t) + a, b := twoAccounts(t, users) + becomeFriends(t, friends, a, b) + ctxA := ctxUser(t, a.ID, "10.0.0.1") + ctxB := ctxUser(t, b.ID, "10.0.0.2") + post, err := svc.Publish(ctxA, a.ID, &PublishInput{Text: "hi"}) + if err != nil { + t.Fatal(err) + } + if _, err := svc.Like(ctxB, b.ID, post.ID); err != nil { + t.Fatal(err) + } + unliked, err := svc.Unlike(ctxB, b.ID, post.ID) + if err != nil || unliked.Liked || unliked.LikeCount != 0 { + t.Fatalf("unlike %+v %v", unliked, err) + } + commented, err := svc.Comment(ctxB, b.ID, post.ID, &CommentInput{Text: "nice"}) + if err != nil || commented.CommentCount != 1 { + t.Fatalf("comment %+v %v", commented, err) + } + cid := commented.Comments[0].ID + replied, err := svc.Comment(ctxA, a.ID, post.ID, &CommentInput{Text: "thanks", ReplyToID: cid}) + if err != nil || replied.CommentCount != 2 { + t.Fatalf("reply %+v %v", replied, err) + } + if _, err := svc.DeleteComment(ctxB, b.ID, post.ID, cid); err != nil { + t.Fatal(err) + } + got, err := svc.Get(ctxA, a.ID, post.ID) + if err != nil || got.CommentCount != 1 { + t.Fatalf("deleted comment %+v %v", got, err) + } + if err := svc.Delete(ctxA, a.ID, post.ID); err != nil { + t.Fatal(err) + } + feed, err := svc.Feed(ctxB, b.ID, 0, 20) + if err != nil || len(feed.Items) != 0 { + t.Fatalf("deleted feed %+v %v", feed, err) + } + post2, err := svc.Publish(ctxA, a.ID, &PublishInput{ + Images: []*entity.Image{{Key: "perm/square/image/20260922/abcdefghijklmnop.jpg"}}, + }) + if err != nil { + t.Fatal(err) + } + if err := svc.ReadNotices(ctxA, a.ID, &NoticeReadInput{Cursor: 0}); err != nil { + t.Fatal(err) + } + unread, err := svc.NoticeUnread(ctxA, a.ID) + if err != nil || unread.HasNew { + t.Fatalf("after read %+v %v", unread, err) + } + if err := svc.PurgeUser(ctxA, a.ID); err != nil { + t.Fatal(err) + } + if _, err := svc.Get(ctxA, a.ID, post2.ID); !errors.Is(err, errs.ErrNotFound) { + t.Fatalf("purged get %v", err) + } +} + +func TestSquareImageKeyRejectedIfChatPrefix(t *testing.T) { + svc, _, users := testSquare(t) + a, _ := twoAccounts(t, users) + ctx := ctxUser(t, a.ID, "10.0.0.1") + _, err := svc.Publish(ctx, a.ID, &PublishInput{ + Images: []*entity.Image{{Key: "chat/image/20260918/xxxx.jpg"}}, + }) + if err == nil { + t.Fatal("accepted chat key") + } +} diff --git a/internal/module/square/logic/view.go b/internal/module/square/logic/view.go new file mode 100644 index 0000000..055d5b2 --- /dev/null +++ b/internal/module/square/logic/view.go @@ -0,0 +1,104 @@ +package logic + +import ( + "context" + "encoding/json" + + "spark-server/internal/module/square/entity" + userentity "spark-server/internal/module/user/entity" + "spark-server/internal/pkg/errs" +) + +type UserCard struct { + UID int64 `json:"uid"` + SparkID string `json:"spark_id"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` + Remark string `json:"remark"` +} + +type CommentView struct { + ID int64 `json:"id"` + User UserCard `json:"user"` + ReplyToID int64 `json:"reply_to_id,omitempty"` + ReplyToUID int64 `json:"reply_to_uid,omitempty"` + Text string `json:"text"` + Deleted bool `json:"deleted,omitempty"` + CreatedAt int64 `json:"created_at"` +} + +type View struct { + ID int64 `json:"id"` + User UserCard `json:"user"` + Kind entity.Kind `json:"kind"` + Text string `json:"text"` + Content json.RawMessage `json:"content"` + POI *entity.POI `json:"poi,omitempty"` + Visibility entity.Visibility `json:"visibility"` + LikeCount int `json:"like_count"` + CommentCount int `json:"comment_count"` + Liked bool `json:"liked"` + Likes []UserCard `json:"likes"` + Comments []*CommentView `json:"comments"` + CreatedAt int64 `json:"created_at"` +} + +type ListResult struct { + Items []*View `json:"items"` + NextCursor int64 `json:"next_cursor"` + HasMore bool `json:"has_more"` +} + +func (s *Service) Get(ctx context.Context, viewerID, postID int64) (*View, error) { + if !s.ready() || viewerID <= 0 { + return nil, errs.ErrUnauthorized + } + if postID <= 0 { + return nil, errs.ErrInvalidArgument + } + post, err := s.repo.GetPost(ctx, postID) + if err != nil { + return nil, err + } + vis, err := s.visFor(ctx, viewerID, []*entity.Post{post}) + if err != nil { + return nil, err + } + if !vis.canSee(post) { + return nil, errs.ErrNotFound + } + return s.viewOf(ctx, viewerID, post, true) +} + +func (s *Service) viewOf(ctx context.Context, viewer int64, post *entity.Post, full bool) (*View, error) { + views, err := s.attachViews(ctx, viewer, []*entity.Post{post}, full) + if err != nil { + return nil, err + } + if len(views) == 0 { + return nil, errs.ErrNotFound + } + return views[0], nil +} + +func cardOf(u *userentity.User, remark string) UserCard { + if u == nil { + return UserCard{Remark: remark} + } + return UserCard{UID: u.ID, SparkID: u.SparkID, Nickname: u.Nickname, Avatar: u.Avatar, Remark: remark} +} + +func poiPtr(p entity.POI) *entity.POI { + if !p.Set() { + return nil + } + cp := p + return &cp +} + +func contentJSON(raw []byte) json.RawMessage { + if len(raw) == 0 { + return json.RawMessage(`{}`) + } + return json.RawMessage(raw) +} diff --git a/internal/module/square/logic/visible.go b/internal/module/square/logic/visible.go new file mode 100644 index 0000000..d188c26 --- /dev/null +++ b/internal/module/square/logic/visible.go @@ -0,0 +1,83 @@ +package logic + +import ( + "context" + + "spark-server/internal/module/square/entity" +) + +type visCtx struct { + viewer int64 + blocked map[int64]bool + audience map[int64][]int64 +} + +func (v *visCtx) canSee(p *entity.Post) bool { + if p == nil || p.Status != entity.StatusNormal || p.AuditStatus != entity.AuditApproved { + return false + } + if p.UserID != v.viewer && v.blocked[p.UserID] { + return false + } + return visibilityOK(p, v.viewer, v.audience[p.ID]) +} + +func visibilityOK(p *entity.Post, viewer int64, peers []int64) bool { + if p.UserID == viewer { + return true + } + switch p.Visibility { + case entity.VisibilityPublic: + return true + case entity.VisibilityPrivate: + return false + case entity.VisibilityInclude: + return containsID(peers, viewer) + case entity.VisibilityExclude: + return !containsID(peers, viewer) + default: + return false + } +} + +func containsID(ids []int64, want int64) bool { + for _, id := range ids { + if id == want { + return true + } + } + return false +} + +func (s *Service) visFor(ctx context.Context, viewer int64, posts []*entity.Post) (*visCtx, error) { + v := &visCtx{viewer: viewer, blocked: map[int64]bool{}, audience: map[int64][]int64{}} + seen := map[int64]struct{}{} + for _, p := range posts { + if p == nil || p.UserID == viewer { + continue + } + if _, ok := seen[p.UserID]; ok { + continue + } + seen[p.UserID] = struct{}{} + blocked, err := s.friends.BlockedEither(ctx, viewer, p.UserID) + if err != nil { + return nil, err + } + if blocked { + v.blocked[p.UserID] = true + } + } + needAud := make([]int64, 0) + for _, p := range posts { + if p != nil && p.Visibility.NeedsAudience() { + needAud = append(needAud, p.ID) + } + } + aud, err := s.repo.ListAudience(ctx, needAud) + if err != nil { + return nil, err + } + v.audience = aud + return v, nil +} diff --git a/internal/module/square/model/memory.go b/internal/module/square/model/memory.go new file mode 100644 index 0000000..d365804 --- /dev/null +++ b/internal/module/square/model/memory.go @@ -0,0 +1,224 @@ +package model + +import ( + "context" + "sort" + "sync" + + "spark-server/internal/module/square/entity" + "spark-server/internal/pkg/errs" +) + +type likeKey struct { + PostID int64 + UserID int64 +} + +// MemoryRepo is an in-process square store for tests and empty-DSN bring-up. +type MemoryRepo struct { + mu sync.Mutex + posts map[int64]*entity.Post + audience map[int64][]int64 + likes map[likeKey]*entity.Like + comments map[int64]*entity.Comment + notices map[int64]*entity.Notice + noticeState map[int64]*entity.NoticeState +} + +func NewMemoryRepo() *MemoryRepo { + return &MemoryRepo{ + posts: make(map[int64]*entity.Post), + audience: make(map[int64][]int64), + likes: make(map[likeKey]*entity.Like), + comments: make(map[int64]*entity.Comment), + notices: make(map[int64]*entity.Notice), + noticeState: make(map[int64]*entity.NoticeState), + } +} + +func (m *MemoryRepo) GetPost(_ context.Context, id int64) (*entity.Post, error) { + m.mu.Lock() + defer m.mu.Unlock() + row, ok := m.posts[id] + if !ok { + return nil, errs.ErrNotFound + } + return clonePost(row), nil +} + +func (m *MemoryRepo) ListByAuthors(_ context.Context, authorIDs []int64, cursor int64, limit int) ([]*entity.Post, error) { + m.mu.Lock() + defer m.mu.Unlock() + allow := toSet(authorIDs) + out := make([]*entity.Post, 0) + for _, row := range m.posts { + if row.Status != entity.StatusNormal || !allow[row.UserID] { + continue + } + if cursor > 0 && row.ID >= cursor { + continue + } + out = append(out, clonePost(row)) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID }) + if limit > 0 && len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (m *MemoryRepo) ListByAuthor(ctx context.Context, authorID, cursor int64, limit int) ([]*entity.Post, error) { + return m.ListByAuthors(ctx, []int64{authorID}, cursor, limit) +} + +func (m *MemoryRepo) ListAudience(_ context.Context, postIDs []int64) (map[int64][]int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + allow := toSet(postIDs) + out := make(map[int64][]int64) + for id, peers := range m.audience { + if !allow[id] { + continue + } + out[id] = append([]int64(nil), peers...) + } + return out, nil +} + +func (m *MemoryRepo) ListLikes(_ context.Context, postIDs []int64) (map[int64][]*entity.Like, error) { + m.mu.Lock() + defer m.mu.Unlock() + allow := toSet(postIDs) + out := make(map[int64][]*entity.Like) + for _, like := range m.likes { + if !allow[like.PostID] { + continue + } + cp := *like + out[like.PostID] = append(out[like.PostID], &cp) + } + for id := range out { + sort.Slice(out[id], func(i, j int) bool { return out[id][i].CreatedAtMS < out[id][j].CreatedAtMS }) + } + return out, nil +} + +func (m *MemoryRepo) ListComments(_ context.Context, postIDs []int64) (map[int64][]*entity.Comment, error) { + m.mu.Lock() + defer m.mu.Unlock() + allow := toSet(postIDs) + out := make(map[int64][]*entity.Comment) + for _, c := range m.comments { + if !allow[c.PostID] { + continue + } + out[c.PostID] = append(out[c.PostID], cloneComment(c)) + } + for id := range out { + sort.Slice(out[id], func(i, j int) bool { return out[id][i].ID < out[id][j].ID }) + } + return out, nil +} + +func (m *MemoryRepo) GetComment(_ context.Context, id int64) (*entity.Comment, error) { + m.mu.Lock() + defer m.mu.Unlock() + row, ok := m.comments[id] + if !ok { + return nil, errs.ErrNotFound + } + return cloneComment(row), nil +} + +func (m *MemoryRepo) ListPublicFeed(_ context.Context, cursor int64, limit int) ([]*entity.Post, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]*entity.Post, 0) + for _, row := range m.posts { + if row.Status != entity.StatusNormal || row.AuditStatus != entity.AuditApproved { + continue + } + if row.Visibility != entity.VisibilityPublic && row.Visibility != entity.VisibilityExclude { + continue + } + if cursor > 0 && row.ID >= cursor { + continue + } + out = append(out, clonePost(row)) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID }) + if limit > 0 && len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (m *MemoryRepo) GetNoticeState(_ context.Context, userID int64) (*entity.NoticeState, error) { + m.mu.Lock() + defer m.mu.Unlock() + if row, ok := m.noticeState[userID]; ok { + cp := *row + return &cp, nil + } + return entity.DefaultNoticeState(userID), nil +} + +func (m *MemoryRepo) LatestNoticeID(_ context.Context, userID int64) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + var latest int64 + for _, n := range m.notices { + if n.ToUserID == userID && n.ID > latest { + latest = n.ID + } + } + return latest, nil +} + +func (m *MemoryRepo) ListNotices(_ context.Context, userID, cursor int64, limit int) ([]*entity.Notice, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]*entity.Notice, 0) + for _, n := range m.notices { + if n.ToUserID != userID { + continue + } + if cursor > 0 && n.ID >= cursor { + continue + } + cp := *n + out = append(out, &cp) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID }) + if limit > 0 && len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func toSet(ids []int64) map[int64]bool { + out := make(map[int64]bool, len(ids)) + for _, id := range ids { + out[id] = true + } + return out +} + +func clonePost(p *entity.Post) *entity.Post { + if p == nil { + return nil + } + cp := *p + if p.Content != nil { + cp.Content = append([]byte(nil), p.Content...) + } + return &cp +} + +func cloneComment(c *entity.Comment) *entity.Comment { + if c == nil { + return nil + } + cp := *c + return &cp +} diff --git a/internal/module/square/model/memory_purge.go b/internal/module/square/model/memory_purge.go new file mode 100644 index 0000000..1c645b2 --- /dev/null +++ b/internal/module/square/model/memory_purge.go @@ -0,0 +1,87 @@ +package model + +import ( + "context" + + "spark-server/internal/module/square/entity" + "spark-server/internal/pkg/errs" +) + +func (m *MemoryRepo) ListAllByAuthor(_ context.Context, authorID int64) ([]*entity.Post, error) { + if authorID <= 0 { + return nil, errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + out := make([]*entity.Post, 0) + for _, row := range m.posts { + if row.UserID == authorID { + out = append(out, clonePost(row)) + } + } + return out, nil +} + +func (m *MemoryRepo) PurgeUser(_ context.Context, userID int64) error { + if userID <= 0 { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + owned := map[int64]bool{} + for id, post := range m.posts { + if post.UserID == userID { + owned[id] = true + } + } + for _, like := range m.likes { + if like.UserID != userID || owned[like.PostID] { + continue + } + if post, ok := m.posts[like.PostID]; ok && post.LikeCount > 0 { + post.LikeCount-- + } + } + for _, c := range m.comments { + if c.UserID != userID || owned[c.PostID] || c.Status == entity.StatusDeleted { + continue + } + if post, ok := m.posts[c.PostID]; ok && post.CommentCount > 0 { + post.CommentCount-- + } + } + for key, like := range m.likes { + if like.UserID == userID || owned[like.PostID] { + delete(m.likes, key) + } + } + for id, c := range m.comments { + if c.UserID == userID || owned[c.PostID] { + delete(m.comments, id) + } + } + for id, n := range m.notices { + if n.ToUserID == userID || n.FromUserID == userID || owned[n.PostID] { + delete(m.notices, id) + } + } + for postID, peers := range m.audience { + if owned[postID] { + delete(m.audience, postID) + continue + } + kept := peers[:0] + for _, peer := range peers { + if peer != userID { + kept = append(kept, peer) + } + } + m.audience[postID] = kept + } + delete(m.noticeState, userID) + for id := range owned { + delete(m.posts, id) + delete(m.audience, id) + } + return nil +} diff --git a/internal/module/square/model/memory_write.go b/internal/module/square/model/memory_write.go new file mode 100644 index 0000000..f4309e8 --- /dev/null +++ b/internal/module/square/model/memory_write.go @@ -0,0 +1,156 @@ +package model + +import ( + "context" + "time" + + "spark-server/internal/module/square/entity" + "spark-server/internal/pkg/errs" +) + +func (m *MemoryRepo) CreatePost(_ context.Context, post *entity.Post, audience []int64) error { + if post == nil || post.ID <= 0 || post.UserID <= 0 { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.posts[post.ID]; ok { + return errs.ErrDuplicate + } + m.posts[post.ID] = clonePost(post) + if len(audience) > 0 { + m.audience[post.ID] = append([]int64(nil), audience...) + } + return nil +} + +func (m *MemoryRepo) SoftDeletePost(_ context.Context, userID, postID int64) (*entity.Post, error) { + m.mu.Lock() + defer m.mu.Unlock() + row, ok := m.posts[postID] + if !ok || row.UserID != userID || row.Status == entity.StatusDeleted { + return nil, errs.ErrNotFound + } + row.Status = entity.StatusDeleted + return clonePost(row), nil +} + +func (m *MemoryRepo) AddLike(_ context.Context, like *entity.Like) (bool, error) { + if like == nil || like.PostID <= 0 || like.UserID <= 0 { + return false, errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + post, ok := m.posts[like.PostID] + if !ok { + return false, errs.ErrNotFound + } + key := likeKey{PostID: like.PostID, UserID: like.UserID} + if _, ok := m.likes[key]; ok { + return false, nil + } + cp := *like + if cp.CreatedAtMS == 0 { + cp.CreatedAtMS = time.Now().UnixMilli() + } + m.likes[key] = &cp + post.LikeCount++ + return true, nil +} + +func (m *MemoryRepo) RemoveLike(_ context.Context, postID, userID int64) error { + m.mu.Lock() + defer m.mu.Unlock() + key := likeKey{PostID: postID, UserID: userID} + if _, ok := m.likes[key]; !ok { + return errs.ErrNotFound + } + delete(m.likes, key) + if post, ok := m.posts[postID]; ok && post.LikeCount > 0 { + post.LikeCount-- + } + return nil +} + +func (m *MemoryRepo) AddComment(_ context.Context, c *entity.Comment) error { + if c == nil || c.ID <= 0 || c.PostID <= 0 || c.UserID <= 0 { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + post, ok := m.posts[c.PostID] + if !ok { + return errs.ErrNotFound + } + if _, ok := m.comments[c.ID]; ok { + return errs.ErrDuplicate + } + m.comments[c.ID] = cloneComment(c) + post.CommentCount++ + return nil +} + +func (m *MemoryRepo) SoftDeleteComment(_ context.Context, id int64) error { + m.mu.Lock() + defer m.mu.Unlock() + row, ok := m.comments[id] + if !ok || row.Status == entity.StatusDeleted { + return errs.ErrNotFound + } + row.Status = entity.StatusDeleted + if post, ok := m.posts[row.PostID]; ok && post.CommentCount > 0 { + post.CommentCount-- + } + return nil +} + +func (m *MemoryRepo) CreateNotices(_ context.Context, rows []*entity.Notice) error { + m.mu.Lock() + defer m.mu.Unlock() + for _, n := range rows { + if n == nil || n.ID <= 0 { + return errs.ErrInvalidArgument + } + if _, ok := m.notices[n.ID]; ok { + return errs.ErrDuplicate + } + cp := *n + m.notices[n.ID] = &cp + } + return nil +} + +func (m *MemoryRepo) MarkNoticesRead(_ context.Context, userID, cursor int64) error { + m.mu.Lock() + defer m.mu.Unlock() + now := time.Now().UnixMilli() + for _, n := range m.notices { + if n.ToUserID != userID || n.ReadAtMS > 0 { + continue + } + if cursor > 0 && n.ID > cursor { + continue + } + n.ReadAtMS = now + } + return nil +} + +func (m *MemoryRepo) AdvanceNoticeRead(_ context.Context, userID, noticeID int64) error { + if userID <= 0 || noticeID <= 0 { + return errs.ErrInvalidArgument + } + m.mu.Lock() + defer m.mu.Unlock() + cur := m.noticeState[userID] + if cur == nil { + cur = entity.DefaultNoticeState(userID) + m.noticeState[userID] = cur + } + if cur.LastReadNoticeID >= noticeID { + return nil + } + cur.LastReadNoticeID = noticeID + cur.UpdatedAtMS = time.Now().UnixMilli() + return nil +} diff --git a/internal/module/square/model/post.go b/internal/module/square/model/post.go new file mode 100644 index 0000000..dbfad18 --- /dev/null +++ b/internal/module/square/model/post.go @@ -0,0 +1,175 @@ +package model + +import ( + "time" + + "spark-server/internal/module/square/entity" +) + +// Post maps table square_posts. +type Post struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement:false;comment:Snowflake 帖 id"` + UserID int64 `gorm:"column:user_id;comment:作者 users.id"` + Kind int16 `gorm:"column:kind;comment:1纯文 2图 3视频"` + Text string `gorm:"column:text;comment:正文"` + Content []byte `gorm:"column:content;type:varbinary(16384);comment:序列化 JSON 字节"` + POILat float64 `gorm:"column:poi_lat;comment:地点纬度"` + POILng float64 `gorm:"column:poi_lng;comment:地点经度"` + POIName string `gorm:"column:poi_name;comment:地点名称"` + POIAddress string `gorm:"column:poi_address;comment:地点地址"` + Visibility int16 `gorm:"column:visibility;comment:0公开 1私密 2白名单 3黑名单"` + AuditStatus int16 `gorm:"column:audit_status;comment:0待审 1通过 2拒绝"` + AuditNote string `gorm:"column:audit_note;comment:拒绝原因"` + AuditedAt *time.Time `gorm:"column:audited_at;comment:审核时间"` + Status int16 `gorm:"column:status;comment:0正常 1已删"` + LikeCount int `gorm:"column:like_count;comment:点赞数"` + CommentCount int `gorm:"column:comment_count;comment:评论数"` + CreatedAt time.Time `gorm:"column:created_at;comment:发帖时间"` +} + +func (Post) TableName() string { return "square_posts" } + +func (r *Post) Entity() *entity.Post { + if r == nil { + return nil + } + p := &entity.Post{ + ID: r.ID, UserID: r.UserID, Kind: entity.Kind(r.Kind), Text: r.Text, Content: r.Content, + POI: entity.POI{Lat: r.POILat, Lng: r.POILng, Name: r.POIName, Address: r.POIAddress}, + Visibility: entity.Visibility(r.Visibility), AuditStatus: entity.AuditStatus(r.AuditStatus), + Status: entity.Status(r.Status), LikeCount: r.LikeCount, CommentCount: r.CommentCount, + CreatedAtMS: r.CreatedAt.UnixMilli(), + } + return p +} + +func postFrom(p *entity.Post) *Post { + if p == nil { + return nil + } + row := &Post{ + ID: p.ID, UserID: p.UserID, Kind: int16(p.Kind), Text: p.Text, Content: p.Content, + POILat: p.POI.Lat, POILng: p.POI.Lng, POIName: p.POI.Name, POIAddress: p.POI.Address, + Visibility: int16(p.Visibility), AuditStatus: int16(p.AuditStatus), Status: int16(p.Status), + LikeCount: p.LikeCount, CommentCount: p.CommentCount, + } + if p.CreatedAtMS > 0 { + row.CreatedAt = time.UnixMilli(p.CreatedAtMS) + } + if p.AuditStatus == entity.AuditApproved { + t := row.CreatedAt + if t.IsZero() { + t = time.Now() + } + row.AuditedAt = &t + } + return row +} + +// Audience maps square_audience. +type Audience struct { + PostID int64 `gorm:"column:post_id;primaryKey;comment:帖 id"` + PeerID int64 `gorm:"column:peer_id;primaryKey;comment:好友 uid"` +} + +func (Audience) TableName() string { return "square_audience" } + +// Like maps square_likes. +type Like struct { + PostID int64 `gorm:"column:post_id;primaryKey;comment:帖 id"` + UserID int64 `gorm:"column:user_id;primaryKey;comment:点赞人"` + CreatedAt time.Time `gorm:"column:created_at;comment:点赞时间"` +} + +func (Like) TableName() string { return "square_likes" } + +func (r *Like) Entity() *entity.Like { + if r == nil { + return nil + } + return &entity.Like{PostID: r.PostID, UserID: r.UserID, CreatedAtMS: r.CreatedAt.UnixMilli()} +} + +// Comment maps square_comments. +type Comment struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement:false;comment:Snowflake 评论 id"` + PostID int64 `gorm:"column:post_id;comment:帖 id"` + UserID int64 `gorm:"column:user_id;comment:评论人"` + ReplyToID int64 `gorm:"column:reply_to_id;comment:父评论 id"` + ReplyToUID int64 `gorm:"column:reply_to_uid;comment:被回复用户"` + Text string `gorm:"column:text;comment:评论文本"` + Status int16 `gorm:"column:status;comment:0正常 1已删"` + CreatedAt time.Time `gorm:"column:created_at;comment:评论时间"` +} + +func (Comment) TableName() string { return "square_comments" } + +func (r *Comment) Entity() *entity.Comment { + if r == nil { + return nil + } + return &entity.Comment{ + ID: r.ID, PostID: r.PostID, UserID: r.UserID, ReplyToID: r.ReplyToID, ReplyToUID: r.ReplyToUID, + Text: r.Text, Status: entity.Status(r.Status), CreatedAtMS: r.CreatedAt.UnixMilli(), + } +} + +func commentFrom(c *entity.Comment) *Comment { + if c == nil { + return nil + } + row := &Comment{ + ID: c.ID, PostID: c.PostID, UserID: c.UserID, ReplyToID: c.ReplyToID, ReplyToUID: c.ReplyToUID, + Text: c.Text, Status: int16(c.Status), + } + if c.CreatedAtMS > 0 { + row.CreatedAt = time.UnixMilli(c.CreatedAtMS) + } + return row +} + +// Notice maps square_notices. +type Notice struct { + ID int64 `gorm:"column:id;primaryKey;autoIncrement:false;comment:Snowflake 通知 id"` + ToUserID int64 `gorm:"column:to_user_id;comment:接收人"` + FromUserID int64 `gorm:"column:from_user_id;comment:发起人"` + Kind int16 `gorm:"column:kind;comment:1赞 2评论 3回复"` + PostID int64 `gorm:"column:post_id;comment:帖 id"` + CommentID int64 `gorm:"column:comment_id;comment:评论 id"` + ReadAt *time.Time `gorm:"column:read_at;comment:已读时间"` + CreatedAt time.Time `gorm:"column:created_at;comment:通知时间"` +} + +func (Notice) TableName() string { return "square_notices" } + +func (r *Notice) Entity() *entity.Notice { + if r == nil { + return nil + } + n := &entity.Notice{ + ID: r.ID, ToUserID: r.ToUserID, FromUserID: r.FromUserID, Kind: entity.NoticeKind(r.Kind), + PostID: r.PostID, CommentID: r.CommentID, CreatedAtMS: r.CreatedAt.UnixMilli(), + } + if r.ReadAt != nil { + n.ReadAtMS = r.ReadAt.UnixMilli() + } + return n +} + +// NoticeState maps square_notice_state. +type NoticeState struct { + UserID int64 `gorm:"column:user_id;primaryKey;comment:用户 id"` + LastReadNoticeID int64 `gorm:"column:last_read_notice_id;comment:已读水位"` + UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间"` +} + +func (NoticeState) TableName() string { return "square_notice_state" } + +func (r *NoticeState) Entity() *entity.NoticeState { + if r == nil { + return nil + } + return &entity.NoticeState{ + UserID: r.UserID, LastReadNoticeID: r.LastReadNoticeID, UpdatedAtMS: r.UpdatedAt.UnixMilli(), + } +} diff --git a/internal/module/square/model/purge.go b/internal/module/square/model/purge.go new file mode 100644 index 0000000..877bfab --- /dev/null +++ b/internal/module/square/model/purge.go @@ -0,0 +1,102 @@ +package model + +import ( + "context" + + "gorm.io/gorm" + + "spark-server/internal/module/square/entity" + "spark-server/internal/pkg/errs" +) + +func (m *Repo) ListAllByAuthor(ctx context.Context, authorID int64) ([]*entity.Post, error) { + if authorID <= 0 { + return nil, errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var rows []Post + if err := db.Where("user_id = ?", authorID).Find(&rows).Error; err != nil { + return nil, mapDBErr(err) + } + return collectPosts(rows), nil +} + +func (m *Repo) PurgeUser(ctx context.Context, userID int64) error { + if userID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + return db.Transaction(func(tx *gorm.DB) error { + return purgeSquareUser(tx, userID) + }) +} + +func purgeSquareUser(tx *gorm.DB, userID int64) error { + if err := bumpOffUserLikes(tx, userID); err != nil { + return err + } + if err := bumpOffUserComments(tx, userID); err != nil { + return err + } + if err := mapDBErr(tx.Where("user_id = ?", userID).Delete(&Like{}).Error); err != nil { + return err + } + if err := mapDBErr(tx.Where("user_id = ?", userID).Delete(&Comment{}).Error); err != nil { + return err + } + if err := mapDBErr(tx.Where("to_user_id = ? OR from_user_id = ?", userID, userID).Delete(&Notice{}).Error); err != nil { + return err + } + if err := mapDBErr(tx.Where("peer_id = ?", userID).Delete(&Audience{}).Error); err != nil { + return err + } + if err := mapDBErr(tx.Where("user_id = ?", userID).Delete(&NoticeState{}).Error); err != nil { + return err + } + return deleteAuthoredPosts(tx, userID) +} + +func bumpOffUserLikes(tx *gorm.DB, userID int64) error { + sql := `UPDATE square_posts p +INNER JOIN (SELECT post_id, COUNT(*) AS n FROM square_likes WHERE user_id = ? GROUP BY post_id) x ON p.id = x.post_id +SET p.like_count = CASE WHEN p.like_count < x.n THEN 0 ELSE p.like_count - x.n END +WHERE p.user_id <> ?` + return mapDBErr(tx.Exec(sql, userID, userID).Error) +} + +func bumpOffUserComments(tx *gorm.DB, userID int64) error { + sql := `UPDATE square_posts p +INNER JOIN (SELECT post_id, COUNT(*) AS n FROM square_comments WHERE user_id = ? AND status = ? GROUP BY post_id) x ON p.id = x.post_id +SET p.comment_count = CASE WHEN p.comment_count < x.n THEN 0 ELSE p.comment_count - x.n END +WHERE p.user_id <> ?` + return mapDBErr(tx.Exec(sql, userID, entity.StatusNormal, userID).Error) +} + +func deleteAuthoredPosts(tx *gorm.DB, userID int64) error { + var ids []int64 + if err := tx.Model(&Post{}).Where("user_id = ?", userID).Pluck("id", &ids).Error; err != nil { + return mapDBErr(err) + } + if len(ids) == 0 { + return nil + } + if err := mapDBErr(tx.Where("post_id IN ?", ids).Delete(&Audience{}).Error); err != nil { + return err + } + if err := mapDBErr(tx.Where("post_id IN ?", ids).Delete(&Like{}).Error); err != nil { + return err + } + if err := mapDBErr(tx.Where("post_id IN ?", ids).Delete(&Comment{}).Error); err != nil { + return err + } + if err := mapDBErr(tx.Where("post_id IN ?", ids).Delete(&Notice{}).Error); err != nil { + return err + } + return mapDBErr(tx.Where("id IN ?", ids).Delete(&Post{}).Error) +} diff --git a/internal/module/square/model/query.go b/internal/module/square/model/query.go new file mode 100644 index 0000000..98948f5 --- /dev/null +++ b/internal/module/square/model/query.go @@ -0,0 +1,186 @@ +package model + +import ( + "context" + "errors" + + "gorm.io/gorm" + + "spark-server/internal/module/square/entity" +) + +func (m *Repo) GetPost(ctx context.Context, id int64) (*entity.Post, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var row Post + if err := db.Where("id = ?", id).Take(&row).Error; err != nil { + return nil, mapDBErr(err) + } + return row.Entity(), nil +} + +func (m *Repo) ListByAuthors(ctx context.Context, authorIDs []int64, cursor int64, limit int) ([]*entity.Post, error) { + if len(authorIDs) == 0 { + return []*entity.Post{}, nil + } + db, err := m.session(ctx) + if err != nil { + return nil, err + } + q := db.Where("user_id IN ? AND status = ?", authorIDs, entity.StatusNormal) + if cursor > 0 { + q = q.Where("id < ?", cursor) + } + var rows []Post + if err := q.Order("id DESC").Limit(limit).Find(&rows).Error; err != nil { + return nil, mapDBErr(err) + } + return collectPosts(rows), nil +} + +func (m *Repo) ListByAuthor(ctx context.Context, authorID, cursor int64, limit int) ([]*entity.Post, error) { + return m.ListByAuthors(ctx, []int64{authorID}, cursor, limit) +} + +func (m *Repo) ListPublicFeed(ctx context.Context, cursor int64, limit int) ([]*entity.Post, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + q := db.Where( + "audit_status = ? AND status = ? AND visibility IN ?", + entity.AuditApproved, entity.StatusNormal, + []int16{int16(entity.VisibilityPublic), int16(entity.VisibilityExclude)}, + ) + if cursor > 0 { + q = q.Where("id < ?", cursor) + } + var rows []Post + if err := q.Order("id DESC").Limit(limit).Find(&rows).Error; err != nil { + return nil, mapDBErr(err) + } + return collectPosts(rows), nil +} + +func (m *Repo) ListAudience(ctx context.Context, postIDs []int64) (map[int64][]int64, error) { + out := make(map[int64][]int64) + if len(postIDs) == 0 { + return out, nil + } + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var rows []Audience + if err := db.Where("post_id IN ?", postIDs).Find(&rows).Error; err != nil { + return nil, mapDBErr(err) + } + for _, row := range rows { + out[row.PostID] = append(out[row.PostID], row.PeerID) + } + return out, nil +} + +func (m *Repo) ListLikes(ctx context.Context, postIDs []int64) (map[int64][]*entity.Like, error) { + out := make(map[int64][]*entity.Like) + if len(postIDs) == 0 { + return out, nil + } + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var rows []Like + if err := db.Where("post_id IN ?", postIDs).Order("created_at ASC").Find(&rows).Error; err != nil { + return nil, mapDBErr(err) + } + for i := range rows { + like := rows[i].Entity() + out[like.PostID] = append(out[like.PostID], like) + } + return out, nil +} + +func (m *Repo) ListComments(ctx context.Context, postIDs []int64) (map[int64][]*entity.Comment, error) { + out := make(map[int64][]*entity.Comment) + if len(postIDs) == 0 { + return out, nil + } + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var rows []Comment + if err := db.Where("post_id IN ?", postIDs).Order("id ASC").Find(&rows).Error; err != nil { + return nil, mapDBErr(err) + } + for i := range rows { + c := rows[i].Entity() + out[c.PostID] = append(out[c.PostID], c) + } + return out, nil +} + +func (m *Repo) GetComment(ctx context.Context, id int64) (*entity.Comment, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var row Comment + if err := db.Where("id = ?", id).Take(&row).Error; err != nil { + return nil, mapDBErr(err) + } + return row.Entity(), nil +} + +func (m *Repo) GetNoticeState(ctx context.Context, userID int64) (*entity.NoticeState, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var row NoticeState + if err := db.Where("user_id = ?", userID).Take(&row).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return entity.DefaultNoticeState(userID), nil + } + return nil, mapDBErr(err) + } + return row.Entity(), nil +} + +func (m *Repo) LatestNoticeID(ctx context.Context, userID int64) (int64, error) { + db, err := m.session(ctx) + if err != nil { + return 0, err + } + var row Notice + if err := db.Where("to_user_id = ?", userID).Order("id DESC").Limit(1).Take(&row).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, nil + } + return 0, mapDBErr(err) + } + return row.ID, nil +} + +func (m *Repo) ListNotices(ctx context.Context, userID, cursor int64, limit int) ([]*entity.Notice, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + q := db.Where("to_user_id = ?", userID) + if cursor > 0 { + q = q.Where("id < ?", cursor) + } + var rows []Notice + if err := q.Order("id DESC").Limit(limit).Find(&rows).Error; err != nil { + return nil, mapDBErr(err) + } + out := make([]*entity.Notice, 0, len(rows)) + for i := range rows { + out = append(out, rows[i].Entity()) + } + return out, nil +} diff --git a/internal/module/square/model/repo.go b/internal/module/square/model/repo.go new file mode 100644 index 0000000..e1fcfd0 --- /dev/null +++ b/internal/module/square/model/repo.go @@ -0,0 +1,50 @@ +package model + +import ( + "context" + "errors" + + mysqldriver "github.com/go-sql-driver/mysql" + "gorm.io/gorm" + + "spark-server/internal/module/square/entity" + "spark-server/internal/pkg/errs" +) + +// Repo is MySQL persistence for square. +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("square-mysql", err) +} + +func collectPosts(rows []Post) []*entity.Post { + out := make([]*entity.Post, 0, len(rows)) + for i := range rows { + out = append(out, rows[i].Entity()) + } + return out +} diff --git a/internal/module/square/model/write.go b/internal/module/square/model/write.go new file mode 100644 index 0000000..0f6f7ef --- /dev/null +++ b/internal/module/square/model/write.go @@ -0,0 +1,225 @@ +package model + +import ( + "context" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "spark-server/internal/module/square/entity" + "spark-server/internal/pkg/errs" +) + +func (m *Repo) CreatePost(ctx context.Context, post *entity.Post, audience []int64) error { + if post == nil || post.ID <= 0 || post.UserID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + row := postFrom(post) + if row.CreatedAt.IsZero() { + row.CreatedAt = time.Now() + } + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(row).Error; err != nil { + return mapDBErr(err) + } + for _, peer := range audience { + if err := tx.Create(&Audience{PostID: post.ID, PeerID: peer}).Error; err != nil { + return mapDBErr(err) + } + } + return nil + }) +} + +func (m *Repo) SoftDeletePost(ctx context.Context, userID, postID int64) (*entity.Post, error) { + db, err := m.session(ctx) + if err != nil { + return nil, err + } + var row Post + err = db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("id = ? AND user_id = ?", postID, userID).Take(&row).Error; err != nil { + return mapDBErr(err) + } + if row.Status == int16(entity.StatusDeleted) { + return errs.ErrNotFound + } + res := tx.Model(&row).Update("status", entity.StatusDeleted) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + row.Status = int16(entity.StatusDeleted) + return nil + }) + if err != nil { + return nil, err + } + return row.Entity(), nil +} + +func (m *Repo) AddLike(ctx context.Context, like *entity.Like) (bool, error) { + if like == nil || like.PostID <= 0 || like.UserID <= 0 { + return false, errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return false, err + } + row := Like{PostID: like.PostID, UserID: like.UserID, CreatedAt: time.Now()} + if like.CreatedAtMS > 0 { + row.CreatedAt = time.UnixMilli(like.CreatedAtMS) + } + created := false + err = db.Transaction(func(tx *gorm.DB) error { + res := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "post_id"}, {Name: "user_id"}}, + DoNothing: true, + }).Create(&row) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return nil + } + created = true + return bumpCount(tx, like.PostID, "like_count", 1) + }) + return created, err +} + +func (m *Repo) RemoveLike(ctx context.Context, postID, userID int64) error { + db, err := m.session(ctx) + if err != nil { + return err + } + return db.Transaction(func(tx *gorm.DB) error { + res := tx.Where("post_id = ? AND user_id = ?", postID, userID).Delete(&Like{}) + if res.Error != nil { + return mapDBErr(res.Error) + } + if res.RowsAffected == 0 { + return errs.ErrNotFound + } + return bumpCount(tx, postID, "like_count", -1) + }) +} + +func (m *Repo) AddComment(ctx context.Context, c *entity.Comment) error { + if c == nil || c.ID <= 0 || c.PostID <= 0 || c.UserID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + row := commentFrom(c) + if row.CreatedAt.IsZero() { + row.CreatedAt = time.Now() + } + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(row).Error; err != nil { + return mapDBErr(err) + } + return bumpCount(tx, c.PostID, "comment_count", 1) + }) +} + +func (m *Repo) SoftDeleteComment(ctx context.Context, id int64) error { + db, err := m.session(ctx) + if err != nil { + return err + } + return db.Transaction(func(tx *gorm.DB) error { + var row Comment + if err := tx.Where("id = ?", id).Take(&row).Error; err != nil { + return mapDBErr(err) + } + if row.Status == int16(entity.StatusDeleted) { + return errs.ErrNotFound + } + if err := tx.Model(&row).Update("status", entity.StatusDeleted).Error; err != nil { + return mapDBErr(err) + } + return bumpCount(tx, row.PostID, "comment_count", -1) + }) +} + +func (m *Repo) CreateNotices(ctx context.Context, rows []*entity.Notice) error { + if len(rows) == 0 { + return nil + } + db, err := m.session(ctx) + if err != nil { + return err + } + now := time.Now() + for _, n := range rows { + row := Notice{ + ID: n.ID, ToUserID: n.ToUserID, FromUserID: n.FromUserID, Kind: int16(n.Kind), + PostID: n.PostID, CommentID: n.CommentID, CreatedAt: now, + } + if n.CreatedAtMS > 0 { + row.CreatedAt = time.UnixMilli(n.CreatedAtMS) + } + if err := db.Create(&row).Error; err != nil { + return mapDBErr(err) + } + } + return nil +} + +func (m *Repo) MarkNoticesRead(ctx context.Context, userID, cursor int64) error { + db, err := m.session(ctx) + if err != nil { + return err + } + q := db.Model(&Notice{}).Where("to_user_id = ? AND read_at IS NULL", userID) + if cursor > 0 { + q = q.Where("id <= ?", cursor) + } + return mapDBErr(q.Update("read_at", time.Now()).Error) +} + +func (m *Repo) AdvanceNoticeRead(ctx context.Context, userID, noticeID int64) error { + if userID <= 0 || noticeID <= 0 { + return errs.ErrInvalidArgument + } + db, err := m.session(ctx) + if err != nil { + return err + } + cur, err := m.GetNoticeState(ctx, userID) + if err != nil { + return err + } + if cur.LastReadNoticeID >= noticeID { + return nil + } + cur.LastReadNoticeID = noticeID + cur.UpdatedAtMS = time.Now().UnixMilli() + row := NoticeState{ + UserID: cur.UserID, LastReadNoticeID: cur.LastReadNoticeID, + UpdatedAt: time.UnixMilli(cur.UpdatedAtMS), + } + return mapDBErr(db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"last_read_notice_id", "updated_at"}), + }).Create(&row).Error) +} + +func bumpCount(tx *gorm.DB, postID int64, col string, delta int) error { + expr := col + " + ?" + if delta < 0 { + expr = "CASE WHEN " + col + " + ? < 0 THEN 0 ELSE " + col + " + ? END" + return mapDBErr(tx.Model(&Post{}).Where("id = ?", postID).Update(col, gorm.Expr(expr, delta, delta)).Error) + } + return mapDBErr(tx.Model(&Post{}).Where("id = ?", postID).Update(col, gorm.Expr(expr, delta)).Error) +} diff --git a/internal/module/upload/entity/purpose.go b/internal/module/upload/entity/purpose.go index 4704396..6137927 100644 --- a/internal/module/upload/entity/purpose.go +++ b/internal/module/upload/entity/purpose.go @@ -18,6 +18,8 @@ const ( PurposeMomentImage = "moment_image" PurposeMomentVideo = "moment_video" PurposeMomentCover = "moment_cover" + PurposeSquareImage = "square_image" + PurposeSquareVideo = "square_video" PrefixPerm = "perm/" PrefixChat = "chat/" @@ -33,6 +35,8 @@ const ( PrefixMomentImage = "perm/moment/image/" PrefixMomentVideo = "perm/moment/video/" PrefixMomentCover = "perm/moment/cover/" + PrefixSquareImage = "perm/square/image/" + PrefixSquareVideo = "perm/square/video/" MethodPUT = "PUT" MethodGET = "GET" @@ -107,6 +111,10 @@ func BuildObjectKey(purpose string, ownerID int64, name, day, ext string) string return fmt.Sprintf("%s%s/%s.%s", PrefixMomentVideo, day, name, ext) case PurposeMomentCover: return fmt.Sprintf("%s%s/%s.%s", PrefixMomentCover, day, name, ext) + case PurposeSquareImage: + return fmt.Sprintf("%s%s/%s.%s", PrefixSquareImage, day, name, ext) + case PurposeSquareVideo: + return fmt.Sprintf("%s%s/%s.%s", PrefixSquareVideo, day, name, ext) default: return fmt.Sprintf("%s%s/%s/%s.%s", PrefixChat, chatKind(purpose), day, name, ext) } @@ -153,6 +161,8 @@ var specs = map[string]Spec{ PurposeMomentCover: {Purpose: PurposeMomentCover, MaxSize: maxAvatar, MIMEs: imageMIMEs}, PurposeMomentImage: {Purpose: PurposeMomentImage, MaxSize: maxMoment, MIMEs: momentImageMIMEs}, PurposeMomentVideo: {Purpose: PurposeMomentVideo, MaxSize: maxVideo, MIMEs: videoMIMEs}, + PurposeSquareImage: {Purpose: PurposeSquareImage, MaxSize: maxMoment, MIMEs: momentImageMIMEs}, + PurposeSquareVideo: {Purpose: PurposeSquareVideo, MaxSize: maxVideo, MIMEs: videoMIMEs}, PurposeChatImage: {Purpose: PurposeChatImage, MaxSize: maxImage, MIMEs: imageMIMEs}, PurposeChatGIF: {Purpose: PurposeChatGIF, MaxSize: maxGIF, MIMEs: gifMIMEs}, PurposeChatVoice: {Purpose: PurposeChatVoice, MaxSize: maxVoice, MIMEs: voiceMIMEs}, diff --git a/internal/module/upload/logic/credential.go b/internal/module/upload/logic/credential.go index df816ea..996a096 100644 --- a/internal/module/upload/logic/credential.go +++ b/internal/module/upload/logic/credential.go @@ -13,7 +13,7 @@ import ( // CreateInput requests a single-object Presigned PUT. The client does not choose the key. type CreateInput struct { - Purpose string `json:"purpose" validate:"required,oneof=avatar group_avatar chat_image chat_voice chat_video chat_gif chat_file moment_image moment_video moment_cover" label:"field.purpose" errmsg:"oneof:validate.upload.purpose_invalid"` + Purpose string `json:"purpose" validate:"required" label:"field.purpose" errmsg:"required:validate.upload.purpose_required"` Mime string `json:"mime" validate:"required,max=128" label:"field.mime" errmsg:"required:validate.upload.mime_required;max:validate.upload.mime_invalid"` Size int64 `json:"size" validate:"required,gt=0" label:"field.size" errmsg:"required:validate.upload.size_required;gt:validate.upload.size_invalid"` GroupID int64 `json:"group_id" validate:"omitempty,gt=0" label:"field.group_id"` diff --git a/internal/module/user/logic/delete.go b/internal/module/user/logic/delete.go index 67bf190..6f1de71 100644 --- a/internal/module/user/logic/delete.go +++ b/internal/module/user/logic/delete.go @@ -6,10 +6,18 @@ import ( "strings" "time" + smsentity "spark-server/internal/module/sms/entity" "spark-server/internal/module/user/entity" "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" ) +// DeleteAccountInput confirms account cancellation with an email OTP (purpose=account_delete). +type DeleteAccountInput struct { + VerifyCode string `json:"verify_code" validate:"omitempty" label:"field.verify_code"` +} + // UserDataCleaner removes a canceled user's rows in another module. type UserDataCleaner interface { PurgeUser(ctx context.Context, userID int64) error @@ -49,7 +57,7 @@ func (s *Service) BindAccountCleanup(tokens TokenRevoker, sessions SessionDroppe s.cleaners = append(s.cleaners[:0], cleaners...) } -func (s *Service) DeleteAccount(ctx context.Context, userID int64) error { +func (s *Service) DeleteAccount(ctx context.Context, userID int64, in DeleteAccountInput) error { if !s.ready() || userID <= 0 { return errs.ErrUnauthorized } @@ -61,6 +69,9 @@ func (s *Service) DeleteAccount(ctx context.Context, userID int64) error { if err != nil { return err } + if err := s.verifyDeleteCode(ctx, email, in.VerifyCode); err != nil { + return err + } s.revokeAccess(ctx, userID) if err := s.purgeRelated(ctx, userID); err != nil { return err @@ -69,12 +80,26 @@ func (s *Service) DeleteAccount(ctx context.Context, userID int64) error { return err } s.deleteAvatarObject(ctx, u.Avatar) - if email != "" && s.codes != nil { - _ = s.codes.Del(ctx, email) + if email != "" && s.otp != nil { + s.otp.ClearTarget(ctx, smsentity.ChannelEmail, email) } return nil } +func (s *Service) verifyDeleteCode(ctx context.Context, email, code string) error { + email = entity.NormalizeEmail(email) + if email == "" || entity.IsTestEmail(email) { + return nil + } + if strings.TrimSpace(code) == "" { + return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginCodeRequired, nil)) + } + if s.otp == nil { + return errs.ErrAvailable + } + return s.otp.CheckAndConsume(ctx, smsentity.ChannelEmail, smsentity.PurposeAccountDelete, email, code) +} + func (s *Service) lookupEmail(ctx context.Context, userID int64) (string, error) { email, err := s.repo.GetEmail(ctx, userID) if err == nil { diff --git a/internal/module/user/logic/delete_test.go b/internal/module/user/logic/delete_test.go index 81b843a..c1239f4 100644 --- a/internal/module/user/logic/delete_test.go +++ b/internal/module/user/logic/delete_test.go @@ -6,6 +6,8 @@ import ( "testing" "spark-server/internal/infrastructure/id" + smslogic "spark-server/internal/module/sms/logic" + smsmodel "spark-server/internal/module/sms/model" friendlogic "spark-server/internal/module/friend/logic" friendmodel "spark-server/internal/module/friend/model" momentlogic "spark-server/internal/module/moment/logic" @@ -24,7 +26,7 @@ func TestDeleteAccountArchivesAndAllowsReregister(t *testing.T) { if err != nil { t.Fatal(err) } - if err := svc.DeleteAccount(ctx, u.ID); err != nil { + if err := svc.DeleteAccount(ctx, u.ID, DeleteAccountInput{}); err != nil { t.Fatal(err) } if _, err := repo.GetByID(ctx, u.ID); !errors.Is(err, errs.ErrNotFound) { @@ -52,7 +54,9 @@ func TestDeleteAccountPurgesFriendsAndMoments(t *testing.T) { t.Fatal(err) } repo := model.NewMemoryRepo() - users := NewService(repo, model.NewMemoryCodes(), ids, nil) + store := smsmodel.NewMemoryStore() + smsSvc := smslogic.NewService(store, nil, nil, nil) + users := NewService(repo, smsSvc, ids, nil) friends := friendlogic.NewService(friendmodel.NewMemoryRepo(), users, ids, nil) moments := momentlogic.NewService(momentmodel.NewMemoryRepo(), friends, users, nil, ids) users.BindAccountCleanup(nil, nil, friends, moments) @@ -77,7 +81,7 @@ func TestDeleteAccountPurgesFriendsAndMoments(t *testing.T) { if err != nil || pub == nil { t.Fatalf("publish %+v %v", pub, err) } - if err := users.DeleteAccount(ctx, a.ID); err != nil { + if err := users.DeleteAccount(ctx, a.ID, DeleteAccountInput{}); err != nil { t.Fatal(err) } ok, err := friends.AreFriends(ctx, a.ID, b.ID) diff --git a/internal/module/user/logic/email_code.go b/internal/module/user/logic/email_code.go deleted file mode 100644 index c211da6..0000000 --- a/internal/module/user/logic/email_code.go +++ /dev/null @@ -1,67 +0,0 @@ -package logic - -import ( - "context" - "crypto/rand" - "encoding/binary" - "fmt" - - "spark-server/internal/module/user/entity" - "spark-server/internal/pkg/errs" -) - -const ( - codeTTLSeconds = 300 - codeCooldownSeconds = 60 -) - -// EmailCodeTTLSeconds is the OTP lifetime returned to clients. -const EmailCodeTTLSeconds = codeTTLSeconds - -// CodeStore is the email OTP cache (Redis). -type CodeStore interface { - Put(ctx context.Context, email, code string, ttlSeconds int) error - Get(ctx context.Context, email string) (string, error) - Del(ctx context.Context, email string) error - SetCooldown(ctx context.Context, email string, ttlSeconds int) (bool, error) -} - -func (s *Service) SendEmailCode(ctx context.Context, email string) error { - if s == nil || s.codes == nil { - return errs.ErrAvailable - } - email = entity.NormalizeEmail(email) - if email == "" { - return errs.ErrInvalidArgument - } - if entity.IsTestEmail(email) { - return nil - } - ok, err := s.codes.SetCooldown(ctx, email, codeCooldownSeconds) - if err != nil { - return err - } - if !ok { - return errs.ErrRateLimited - } - code, err := randomDigits(6) - if err != nil { - return errs.ErrInner.WithError("email-code", err) - } - return s.codes.Put(ctx, email, code, codeTTLSeconds) -} - -func randomDigits(n int) (string, error) { - if n <= 0 { - return "", fmt.Errorf("digits") - } - var buf [8]byte - out := make([]byte, n) - for i := 0; i < n; i++ { - if _, err := rand.Read(buf[:4]); err != nil { - return "", err - } - out[i] = '0' + byte(binary.BigEndian.Uint32(buf[:4])%10) - } - return string(out), nil -} diff --git a/internal/module/user/logic/email_code_test.go b/internal/module/user/logic/email_code_test.go deleted file mode 100644 index 0886fdb..0000000 --- a/internal/module/user/logic/email_code_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package logic - -import ( - "errors" - "testing" - - "spark-server/internal/pkg/errs" -) - -func TestSendEmailCodeCooldown(t *testing.T) { - svc, _, _ := testService(t) - if err := svc.SendEmailCode(t.Context(), "test06@spark.com"); err != nil { - t.Fatal(err) - } - if err := svc.SendEmailCode(t.Context(), "ada@example.com"); err != nil { - t.Fatal(err) - } - if err := svc.SendEmailCode(t.Context(), "ada@example.com"); !errors.Is(err, errs.ErrRateLimited) { - t.Fatalf("cooldown %v", err) - } -} diff --git a/internal/module/user/logic/login.go b/internal/module/user/logic/login.go index e512986..dc23598 100644 --- a/internal/module/user/logic/login.go +++ b/internal/module/user/logic/login.go @@ -2,11 +2,11 @@ package logic import ( "context" - "crypto/subtle" "errors" "strings" "time" + smsentity "spark-server/internal/module/sms/entity" "spark-server/internal/module/user/entity" "spark-server/internal/pkg/errs" "spark-server/internal/pkg/i18n" @@ -56,15 +56,10 @@ func (s *Service) checkVerifyCode(ctx context.Context, email, verifyCode string) if entity.IsTestEmail(email) { return nil } - want, err := s.codes.Get(ctx, email) - if err != nil { - return err + if s.otp == nil { + return errs.ErrAvailable } - if want == "" || subtle.ConstantTimeCompare([]byte(want), []byte(verifyCode)) != 1 { - return errs.ErrUnauthorized - } - _ = s.codes.Del(ctx, email) - return nil + return s.otp.CheckAndConsume(ctx, smsentity.ChannelEmail, smsentity.PurposeLogin, email, verifyCode) } func (s *Service) getOrCreate(ctx context.Context, email string) (*entity.User, error) { diff --git a/internal/module/user/logic/login_test.go b/internal/module/user/logic/login_test.go index 0ea6c5f..34121c2 100644 --- a/internal/module/user/logic/login_test.go +++ b/internal/module/user/logic/login_test.go @@ -4,12 +4,16 @@ import ( "bytes" "context" "errors" + "fmt" "image/png" "strconv" "strings" "testing" "spark-server/internal/infrastructure/id" + smslogic "spark-server/internal/module/sms/logic" + smsmodel "spark-server/internal/module/sms/model" + smsentity "spark-server/internal/module/sms/entity" "spark-server/internal/module/user/entity" "spark-server/internal/module/user/model" "spark-server/internal/pkg/errs" @@ -111,10 +115,10 @@ func TestAuthenticateNonTestNeedsCode(t *testing.T) { ctx := t.Context() device := &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceWeb} _, err := svc.AuthenticateEmail(ctx, "ada@example.com", "123456", device) - if !errors.Is(err, errs.ErrUnauthorized) { + if !errors.Is(err, errs.ErrVerifyCodeInvalid) { t.Fatalf("no code: %v", err) } - if err := codes.Put(ctx, "ada@example.com", "123456", 60); err != nil { + if err := codes.Put(ctx, fmt.Sprintf("%d:%d:%s", smsentity.ChannelEmail, smsentity.PurposeLogin, "ada@example.com"), "123456", 60); err != nil { t.Fatal(err) } u, err := svc.AuthenticateEmail(ctx, "ada@example.com", "123456", device) @@ -125,7 +129,7 @@ func TestAuthenticateNonTestNeedsCode(t *testing.T) { t.Fatal(u) } _, err = svc.AuthenticateEmail(ctx, "ada@example.com", "123456", device) - if !errors.Is(err, errs.ErrUnauthorized) { + if !errors.Is(err, errs.ErrVerifyCodeInvalid) { t.Fatalf("consumed: %v", err) } } @@ -157,7 +161,9 @@ func TestRegisterFallsBackToDataURIWithoutObjectStore(t *testing.T) { if err != nil { t.Fatal(err) } - svc := NewService(model.NewMemoryRepo(), model.NewMemoryCodes(), ids, nil) + store := smsmodel.NewMemoryStore() + smsSvc := smslogic.NewService(store, nil, nil, nil) + svc := NewService(model.NewMemoryRepo(), smsSvc, ids, nil) device := &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS} u, err := svc.AuthenticateEmail(t.Context(), "test09@spark.com", "1", device) if err != nil { @@ -173,7 +179,9 @@ func TestRegisterFallsBackWhenObjectPutFails(t *testing.T) { if err != nil { t.Fatal(err) } - svc := NewService(model.NewMemoryRepo(), model.NewMemoryCodes(), ids, failObjects{}) + store := smsmodel.NewMemoryStore() + smsSvc := smslogic.NewService(store, nil, nil, nil) + svc := NewService(model.NewMemoryRepo(), smsSvc, ids, failObjects{}) device := &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS} u, err := svc.AuthenticateEmail(t.Context(), "test09@spark.com", "1", device) if err != nil { diff --git a/internal/module/user/logic/profile_test.go b/internal/module/user/logic/profile_test.go index 037dbdd..774ae76 100644 --- a/internal/module/user/logic/profile_test.go +++ b/internal/module/user/logic/profile_test.go @@ -37,7 +37,7 @@ func TestUpdateSparkID(t *testing.T) { } _, err = svc.UpdateSparkID(ctx, u.ID, "1bad") app, ok := errs.AsAppError(err) - if !ok || app.Code != errs.ErrInvalidArgument.Code || !strings.Contains(app.Msg, "字母开头") { + if !ok || app.Code != errs.ErrInvalidArgument.Code || !strings.Contains(app.Msg, "letter") { t.Fatalf("invalid %v", err) } } diff --git a/internal/module/user/logic/service.go b/internal/module/user/logic/service.go index c49a1cd..10f0dcf 100644 --- a/internal/module/user/logic/service.go +++ b/internal/module/user/logic/service.go @@ -5,6 +5,7 @@ import ( "context" "time" + smsentity "spark-server/internal/module/sms/entity" "spark-server/internal/module/user/entity" ) @@ -37,10 +38,16 @@ type Objects interface { Put(ctx context.Context, key string, body []byte, contentType string) error } +// OTP checks and clears verification codes issued by the sms module. +type OTP interface { + CheckAndConsume(ctx context.Context, ch smsentity.Channel, purpose smsentity.Purpose, target, code string) error + ClearTarget(ctx context.Context, ch smsentity.Channel, target string) +} + // Service is the user application use-case (HTTP and TCP share it). type Service struct { repo Repository - codes CodeStore + otp OTP ids IDGen objects Objects tokens TokenRevoker @@ -48,8 +55,8 @@ type Service struct { cleaners []UserDataCleaner } -func NewService(repo Repository, codes CodeStore, ids IDGen, objects Objects) *Service { - return &Service{repo: repo, codes: codes, ids: ids, objects: objects} +func NewService(repo Repository, otp OTP, ids IDGen, objects Objects) *Service { + return &Service{repo: repo, otp: otp, ids: ids, objects: objects} } func (s *Service) ready() bool { diff --git a/internal/module/user/logic/service_test.go b/internal/module/user/logic/service_test.go index 7b70756..71ac7b1 100644 --- a/internal/module/user/logic/service_test.go +++ b/internal/module/user/logic/service_test.go @@ -5,18 +5,21 @@ import ( "testing" "spark-server/internal/infrastructure/id" + smslogic "spark-server/internal/module/sms/logic" + smsmodel "spark-server/internal/module/sms/model" "spark-server/internal/module/user/model" ) -func testService(t *testing.T) (*Service, *model.MemoryRepo, *model.MemoryCodes) { +func testService(t *testing.T) (*Service, *model.MemoryRepo, smslogic.CodeStore) { t.Helper() ids, err := id.New(1) if err != nil { t.Fatal(err) } repo := model.NewMemoryRepo() - codes := model.NewMemoryCodes() - return NewService(repo, codes, ids, newMemObjects()), repo, codes + store := smsmodel.NewMemoryStore() + smsSvc := smslogic.NewService(store, nil, nil, nil) + return NewService(repo, smsSvc, ids, newMemObjects()), repo, store } type memObjects struct { diff --git a/internal/module/user/logictest/wire.go b/internal/module/user/logictest/wire.go new file mode 100644 index 0000000..a6a4beb --- /dev/null +++ b/internal/module/user/logictest/wire.go @@ -0,0 +1,24 @@ +// Package logictest wires user + in-memory OTP for integration tests in other modules. +package logictest + +import ( + "spark-server/internal/infrastructure/id" + smslogic "spark-server/internal/module/sms/logic" + smsmodel "spark-server/internal/module/sms/model" + userlogic "spark-server/internal/module/user/logic" + usermodel "spark-server/internal/module/user/model" +) + +// Users returns a user service backed by in-memory repo and OTP store. +func Users(ids *id.Generator, objects userlogic.Objects) *userlogic.Service { + svc, _ := UsersAndSMS(ids, objects) + return svc +} + +// UsersAndSMS returns user and sms services sharing the same in-memory OTP store. +func UsersAndSMS(ids *id.Generator, objects userlogic.Objects) (*userlogic.Service, *smslogic.Service) { + repo := usermodel.NewMemoryRepo() + store := smsmodel.NewMemoryStore() + smsSvc := smslogic.NewService(store, nil, nil, nil) + return userlogic.NewService(repo, smsSvc, ids, objects), smsSvc +} diff --git a/internal/module/user/model/email_code.go b/internal/module/user/model/email_code.go deleted file mode 100644 index d54354a..0000000 --- a/internal/module/user/model/email_code.go +++ /dev/null @@ -1,68 +0,0 @@ -package model - -import ( - "context" - "time" - - "github.com/redis/go-redis/v9" - - "spark-server/internal/pkg/errs" -) - -const ( - emailCodeKeyPrefix = "auth:email-code:" - emailCodeCDPrefix = "auth:email-cd:" -) - -type RedisCodes struct { - rdb *redis.Client -} - -func NewRedisCodes(rdb *redis.Client) *RedisCodes { - return &RedisCodes{rdb: rdb} -} - -func (r *RedisCodes) Put(ctx context.Context, email, code string, ttlSeconds int) error { - if r == nil || r.rdb == nil { - return errs.ErrAvailable - } - if err := r.rdb.Set(ctx, emailCodeKeyPrefix+email, code, time.Duration(ttlSeconds)*time.Second).Err(); err != nil { - return errs.ErrAvailable.WithError("email-code", err) - } - return nil -} - -func (r *RedisCodes) Get(ctx context.Context, email string) (string, error) { - if r == nil || r.rdb == nil { - return "", errs.ErrAvailable - } - v, err := r.rdb.Get(ctx, emailCodeKeyPrefix+email).Result() - if err == redis.Nil { - return "", nil - } - if err != nil { - return "", errs.ErrAvailable.WithError("email-code", err) - } - return v, nil -} - -func (r *RedisCodes) Del(ctx context.Context, email string) error { - if r == nil || r.rdb == nil { - return errs.ErrAvailable - } - if err := r.rdb.Del(ctx, emailCodeKeyPrefix+email).Err(); err != nil { - return errs.ErrAvailable.WithError("email-code", err) - } - return nil -} - -func (r *RedisCodes) SetCooldown(ctx context.Context, email string, ttlSeconds int) (bool, error) { - if r == nil || r.rdb == nil { - return false, errs.ErrAvailable - } - ok, err := r.rdb.SetNX(ctx, emailCodeCDPrefix+email, "1", time.Duration(ttlSeconds)*time.Second).Result() - if err != nil { - return false, errs.ErrAvailable.WithError("email-code", err) - } - return ok, nil -} diff --git a/internal/module/user/model/email_code_memory.go b/internal/module/user/model/email_code_memory.go deleted file mode 100644 index a1921cc..0000000 --- a/internal/module/user/model/email_code_memory.go +++ /dev/null @@ -1,57 +0,0 @@ -package model - -import ( - "context" - "sync" - "time" -) - -type MemoryCodes struct { - mu sync.Mutex - codes map[string]codeEntry - cooldowns map[string]time.Time -} - -type codeEntry struct { - code string - exp time.Time -} - -func NewMemoryCodes() *MemoryCodes { - return &MemoryCodes{codes: make(map[string]codeEntry), cooldowns: make(map[string]time.Time)} -} - -func (m *MemoryCodes) Put(_ context.Context, email, code string, ttlSeconds int) error { - m.mu.Lock() - defer m.mu.Unlock() - m.codes[email] = codeEntry{code: code, exp: time.Now().Add(time.Duration(ttlSeconds) * time.Second)} - return nil -} - -func (m *MemoryCodes) Get(_ context.Context, email string) (string, error) { - m.mu.Lock() - defer m.mu.Unlock() - e, ok := m.codes[email] - if !ok || time.Now().After(e.exp) { - delete(m.codes, email) - return "", nil - } - return e.code, nil -} - -func (m *MemoryCodes) Del(_ context.Context, email string) error { - m.mu.Lock() - defer m.mu.Unlock() - delete(m.codes, email) - return nil -} - -func (m *MemoryCodes) SetCooldown(_ context.Context, email string, ttlSeconds int) (bool, error) { - m.mu.Lock() - defer m.mu.Unlock() - if exp, ok := m.cooldowns[email]; ok && time.Now().Before(exp) { - return false, nil - } - m.cooldowns[email] = time.Now().Add(time.Duration(ttlSeconds) * time.Second) - return true, nil -} diff --git a/internal/pkg/errs/codes.go b/internal/pkg/errs/codes.go index 0b33b40..c28d8c6 100644 --- a/internal/pkg/errs/codes.go +++ b/internal/pkg/errs/codes.go @@ -9,6 +9,7 @@ var ( ErrProxyHeader = NewError("40004", "invalid proxy protocol header", "") ErrUnauthorized = NewError("40101", "unauthorized", "") ErrNotAuthenticated = NewError("40102", "not authenticated", "") + ErrVerifyCodeInvalid = NewError("40103", "verification code invalid", "") ErrForbidden = NewError("40301", "forbidden", "") ErrNotFound = NewError("40401", "not found", "") ErrMediaExpired = NewError("40402", "media expired", "") diff --git a/internal/pkg/i18n/catalog_err.go b/internal/pkg/i18n/catalog_err.go index b4856bb..c2d3d86 100644 --- a/internal/pkg/i18n/catalog_err.go +++ b/internal/pkg/i18n/catalog_err.go @@ -23,6 +23,9 @@ func init() { "err.40102": { ZhCN: "未登录", En: "not authenticated", ZhTW: "未登入", }, + "err.40103": { + ZhCN: "验证码无效", En: "Invalid verification code", ZhTW: "驗證碼無效", + }, "err.40301": { ZhCN: "无权限", En: "forbidden", ZhTW: "無權限", }, diff --git a/internal/pkg/i18n/catalog_validate_sms.go b/internal/pkg/i18n/catalog_validate_sms.go new file mode 100644 index 0000000..98fa0fe --- /dev/null +++ b/internal/pkg/i18n/catalog_validate_sms.go @@ -0,0 +1,61 @@ +package i18n + +func init() { + register(map[string]map[Lang]string{ + KeyFieldVerificationTarget: { + ZhCN: "账号", En: "Account", ZhTW: "帳號", + }, + KeySMSLanguageRequired: { + ZhCN: "请填写 X-Language", En: "X-Language header is required", ZhTW: "請填寫 X-Language", + }, + KeySMSTargetRequired: { + ZhCN: "请填写邮箱或手机号", En: "Email or phone is required", ZhTW: "請填寫郵箱或手機號", + }, + KeySMSTargetInvalid: { + ZhCN: "邮箱或手机号格式无效", En: "Invalid email or phone", ZhTW: "郵箱或手機號格式無效", + }, + KeyFieldVerificationPurpose: { + ZhCN: "验证码用途", En: "Verification purpose", ZhTW: "驗證碼用途", + }, + KeySMSPurposeRequired: { + ZhCN: "请选择验证码用途", En: "Verification purpose is required", ZhTW: "請選擇驗證碼用途", + }, + KeySMSPurposeInvalid: { + ZhCN: "验证码用途无效", En: "Invalid verification purpose", ZhTW: "驗證碼用途無效", + }, + KeySMSDeletePhoneUnsupported: { + ZhCN: "注销验证码暂仅支持绑定邮箱", En: "Account deletion codes are email-only for now", ZhTW: "註銷驗證碼暫僅支援綁定郵箱", + }, + KeySMSEmailSubjectLogin: { + ZhCN: "Spark 登录验证码", En: "Spark login code", ZhTW: "Spark 登入驗證碼", + }, + KeySMSEmailBodyLogin: { + ZhCN: "您的登录验证码是 {code},5 分钟内有效。如非本人操作请忽略。", + En: "Your login code is {code}. It expires in 5 minutes. Ignore this if you did not request it.", + ZhTW: "您的登入驗證碼是 {code},5 分鐘內有效。如非本人操作請忽略。", + }, + KeySMSEmailSubjectDelete: { + ZhCN: "Spark 账号注销验证码", En: "Spark account deletion code", ZhTW: "Spark 帳號註銷驗證碼", + }, + KeySMSEmailBodyDelete: { + ZhCN: "您的注销验证码是 {code},5 分钟内有效。提交后将永久删除账号。", + En: "Your account deletion code is {code}. It expires in 5 minutes. Submitting will permanently delete your account.", + ZhTW: "您的註銷驗證碼是 {code},5 分鐘內有效。提交後將永久刪除帳號。", + }, + "enum.verification_purpose.login": { + ZhCN: "登录", En: "Login", ZhTW: "登入", + }, + "enum.verification_purpose.account_delete": { + ZhCN: "注销账号", En: "Delete account", ZhTW: "註銷帳號", + }, + "enum.phone_carrier.mobile": { + ZhCN: "中国移动", En: "China Mobile", ZhTW: "中國移動", + }, + "enum.phone_carrier.unicom": { + ZhCN: "中国联通", En: "China Unicom", ZhTW: "中國聯通", + }, + "enum.phone_carrier.telecom": { + ZhCN: "中国电信", En: "China Telecom", ZhTW: "中國電信", + }, + }) +} diff --git a/internal/pkg/i18n/catalog_validate_square.go b/internal/pkg/i18n/catalog_validate_square.go new file mode 100644 index 0000000..5063191 --- /dev/null +++ b/internal/pkg/i18n/catalog_validate_square.go @@ -0,0 +1,65 @@ +package i18n + +func init() { + register(map[string]map[Lang]string{ + KeyFieldSquareText: { + ZhCN: "正文", En: "Text", ZhTW: "正文", + }, + KeyFieldSquareVisibility: { + ZhCN: "可见范围", En: "Visibility", ZhTW: "可見範圍", + }, + KeyFieldSquareAudience: { + ZhCN: "可见名单", En: "Audience", ZhTW: "可見名單", + }, + KeyFieldSquareComment: { + ZhCN: "评论", En: "Comment", ZhTW: "評論", + }, + KeyFieldSquareReply: { + ZhCN: "回复", En: "Reply", ZhTW: "回覆", + }, + KeyFieldSquareNoticeCursor: { + ZhCN: "游标", En: "Cursor", ZhTW: "遊標", + }, + KeySquareTextInvalid: { + ZhCN: "正文最多1000字", En: "Text must be at most 1000 characters", ZhTW: "正文最多1000字", + }, + KeySquareVisibilityInvalid: { + ZhCN: "可见范围无效", En: "Invalid visibility", ZhTW: "可見範圍無效", + }, + KeySquareAudienceInvalid: { + ZhCN: "可见名单无效", En: "Invalid audience", ZhTW: "可見名單無效", + }, + KeySquareContentInvalid: { + ZhCN: "媒体无效", En: "Invalid media", ZhTW: "媒體無效", + }, + KeySquareMediaConflict: { + ZhCN: "图片和视频不能同时发布", En: "Images and video cannot be posted together", ZhTW: "圖片和影片不能同時發布", + }, + KeySquareEmpty: { + ZhCN: "请填写正文或添加媒体", En: "Add text or media", ZhTW: "請填寫正文或新增媒體", + }, + KeySquareCommentRequired: { + ZhCN: "请填写评论", En: "Please enter a comment", ZhTW: "請填寫評論", + }, + KeySquareCommentInvalid: { + ZhCN: "评论无效", En: "Invalid comment", ZhTW: "評論無效", + }, + KeySquareReplyInvalid: { + ZhCN: "回复目标无效", En: "Invalid reply target", ZhTW: "回覆目標無效", + }, + "enum.square_kind.text": {ZhCN: "纯文", En: "Text", ZhTW: "純文"}, + "enum.square_kind.image": {ZhCN: "图片", En: "Image", ZhTW: "圖片"}, + "enum.square_kind.video": {ZhCN: "视频", En: "Video", ZhTW: "影片"}, + "enum.square_visibility.public": {ZhCN: "全站公开", En: "Public", ZhTW: "全站公開"}, + "enum.square_visibility.private": {ZhCN: "仅自己", En: "Private", ZhTW: "僅自己"}, + "enum.square_visibility.include": {ZhCN: "部分可见", En: "Include", ZhTW: "部分可見"}, + "enum.square_visibility.exclude": {ZhCN: "不给谁看", En: "Exclude", ZhTW: "不給誰看"}, + "enum.square_status.normal": {ZhCN: "正常", En: "Normal", ZhTW: "正常"}, + "enum.square_status.deleted": {ZhCN: "已删除", En: "Deleted", ZhTW: "已刪除"}, + "enum.square_notice_kind.like": {ZhCN: "赞", En: "Like", ZhTW: "讚"}, + "enum.square_notice_kind.comment": {ZhCN: "评论", En: "Comment", ZhTW: "評論"}, + "enum.square_notice_kind.reply": {ZhCN: "回复", En: "Reply", ZhTW: "回覆"}, + "enum.upload_purpose.square_image": {ZhCN: "广场图片", En: "Square image", ZhTW: "广场圖片"}, + "enum.upload_purpose.square_video": {ZhCN: "广场视频", En: "Square video", ZhTW: "广场影片"}, + }) +} diff --git a/internal/pkg/i18n/i18n_test.go b/internal/pkg/i18n/i18n_test.go index 2d3accf..667b221 100644 --- a/internal/pkg/i18n/i18n_test.go +++ b/internal/pkg/i18n/i18n_test.go @@ -4,7 +4,7 @@ import "testing" func TestParseLanguage(t *testing.T) { cases := map[string]Lang{ - "": ZhCN, + "": En, "zh": ZhCN, "zh-CN": ZhCN, "zh_CN": ZhCN, diff --git a/internal/pkg/i18n/keys.go b/internal/pkg/i18n/keys.go index 244c842..ab53738 100644 --- a/internal/pkg/i18n/keys.go +++ b/internal/pkg/i18n/keys.go @@ -148,6 +148,35 @@ const ( KeyMomentVisibleDaysInvalid = "validate.moment.visible_days_invalid" KeyMomentHideNotFriend = "validate.moment.hide_not_friend" + KeyFieldSquareText = "field.square_text" + KeyFieldSquareVisibility = "field.square_visibility" + KeyFieldSquareAudience = "field.square_audience" + KeyFieldSquareComment = "field.square_comment" + KeyFieldSquareReply = "field.square_reply" + KeyFieldSquareNoticeCursor = "field.square_notice_cursor" + KeySquareTextInvalid = "validate.square.text_invalid" + KeySquareVisibilityInvalid = "validate.square.visibility_invalid" + KeySquareAudienceInvalid = "validate.square.audience_invalid" + KeySquareContentInvalid = "validate.square.content_invalid" + KeySquareMediaConflict = "validate.square.media_conflict" + KeySquareEmpty = "validate.square.empty" + KeySquareCommentRequired = "validate.square.comment_required" + KeySquareCommentInvalid = "validate.square.comment_invalid" + KeySquareReplyInvalid = "validate.square.reply_invalid" + + KeyFieldVerificationTarget = "field.verification_target" + KeySMSLanguageRequired = "validate.sms.language_required" + KeySMSTargetRequired = "validate.sms.target_required" + KeySMSTargetInvalid = "validate.sms.target_invalid" + KeyFieldVerificationPurpose = "field.verification_purpose" + KeySMSPurposeRequired = "validate.sms.purpose_required" + KeySMSPurposeInvalid = "validate.sms.purpose_invalid" + KeySMSDeletePhoneUnsupported = "validate.sms.delete_phone_unsupported" + KeySMSEmailSubjectLogin = "sms.email.subject_login" + KeySMSEmailBodyLogin = "sms.email.body_login" + KeySMSEmailSubjectDelete = "sms.email.subject_delete" + KeySMSEmailBodyDelete = "sms.email.body_delete" + NotifyKickReplaced = "notify.kick.replaced" NotifyKickLogout = "notify.kick.logout" NotifyKickDeleted = "notify.kick.deleted" diff --git a/internal/pkg/i18n/locale.go b/internal/pkg/i18n/locale.go index a2f2a2d..44945b7 100644 --- a/internal/pkg/i18n/locale.go +++ b/internal/pkg/i18n/locale.go @@ -11,7 +11,7 @@ const ( ZhCN Lang = "zh-CN" En Lang = "en" ZhTW Lang = "zh-TW" - Default = ZhCN + Default = En ) func (l Lang) String() string { @@ -22,6 +22,7 @@ func (l Lang) String() string { } // Parse maps Accept-Language, X-Language, or TCP AUTH language to ZhCN / En / ZhTW. +// Unknown or empty input falls back to Default (en). func Parse(raw string) Lang { for _, part := range strings.Split(raw, ",") { tag, _, _ := strings.Cut(strings.TrimSpace(part), ";") @@ -32,6 +33,21 @@ func Parse(raw string) Lang { return Default } +// ParseExplicit requires a recognized language tag; ok is false when raw is empty or unknown. +func ParseExplicit(raw string) (Lang, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return Default, false + } + for _, part := range strings.Split(raw, ",") { + tag, _, _ := strings.Cut(strings.TrimSpace(part), ";") + if lang, ok := match(tag); ok { + return lang, true + } + } + return Default, false +} + func match(tag string) (Lang, bool) { t := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(tag), "_", "-")) if t == "" { diff --git a/internal/pkg/reply/reply_test.go b/internal/pkg/reply/reply_test.go index dd66076..1fb659b 100644 --- a/internal/pkg/reply/reply_test.go +++ b/internal/pkg/reply/reply_test.go @@ -30,7 +30,7 @@ func TestOKAndFailEnvelope(t *testing.T) { if fail.Code != errs.ErrNotAuthenticated.Code || fail.Msg != "login first" || string(fail.Data) != "{}" { t.Fatalf("fail %+v", fail) } - zh := FromError(errs.ErrUnauthorized) + zh := FromErrorLang(i18n.ZhCN, errs.ErrUnauthorized) if zh.Msg != "未授权" { t.Fatalf("zh %q", zh.Msg) } diff --git a/internal/pkg/reqctx/reqctx_test.go b/internal/pkg/reqctx/reqctx_test.go index b39e07c..db1bf82 100644 --- a/internal/pkg/reqctx/reqctx_test.go +++ b/internal/pkg/reqctx/reqctx_test.go @@ -87,7 +87,7 @@ func TestLangFromContext(t *testing.T) { if Language(ctx) != "en" { t.Fatalf("got %q", Language(ctx)) } - if Language(t.Context()) != "zh-CN" { + if Language(t.Context()) != "en" { t.Fatalf("default %q", Language(t.Context())) } } diff --git a/internal/pkg/validate/validate.go b/internal/pkg/validate/validate.go index d6d0869..1647af8 100644 --- a/internal/pkg/validate/validate.go +++ b/internal/pkg/validate/validate.go @@ -34,7 +34,7 @@ func engine() *validator.Validate { return v } -// Struct validates v in zh-CN. Prefer StructLang with the request language. +// Struct validates v in the default language (en). Prefer StructLang with the request language. func Struct(val any) error { return StructLang(i18n.Default, val) } diff --git a/internal/pkg/validate/validate_test.go b/internal/pkg/validate/validate_test.go index 22c05c8..a32eb69 100644 --- a/internal/pkg/validate/validate_test.go +++ b/internal/pkg/validate/validate_test.go @@ -23,12 +23,12 @@ func TestStructCustomAndDefaultMessages(t *testing.T) { if app.Code != errs.ErrInvalidArgument.Code { t.Fatalf("code %s", app.Code) } - if !strings.Contains(app.Msg, "请填写手机号") || !strings.Contains(app.Msg, "密码不能为空") || !strings.Contains(app.Msg, "设备ID不能为空") { + if !strings.Contains(app.Msg, "Please enter your phone number") || !strings.Contains(app.Msg, "Password is required") || !strings.Contains(app.Msg, "Device ID is required") { t.Fatalf("msg %s", app.Msg) } err = Struct(sample{Phone: "1", Password: "123", DeviceID: 1}) app, ok = errs.AsAppError(err) - if !ok || !strings.Contains(app.Msg, "密码不能少于6") { + if !ok || !strings.Contains(app.Msg, "at least 6") { t.Fatalf("min %v", err) } if err := Struct(sample{Phone: "1", Password: "123456", DeviceID: 1}); err != nil { diff --git a/internal/transport/http/auth.go b/internal/transport/http/auth.go index 05b3f01..3273be2 100644 --- a/internal/transport/http/auth.go +++ b/internal/transport/http/auth.go @@ -76,7 +76,7 @@ func AuthRateLimit(l *ratelimit.Limiter) gin.HandlerFunc { func authLimitPath(path string) bool { switch path { - case "/v1/auth/login", "/v1/auth/email-codes", "/v1/auth/token/refresh": + case "/v1/auth/login", "/v1/auth/token/refresh", "/v1/sms/codes": return true default: return false @@ -87,7 +87,7 @@ func publicPath(path string) bool { switch path { case "/healthz", "/readyz", "/openapi.yaml", "/docs", "/docs/": return true - case "/v1/meta/enums", "/v1/auth/login", "/v1/auth/email-codes", "/v1/auth/token/refresh": + case "/v1/meta/enums", "/v1/auth/login", "/v1/auth/token/refresh", "/v1/sms/codes": return true default: // Admin routes use AdminAuth (Java popadmin JWT), not client Bearer. diff --git a/internal/transport/http/docs/openapi.yaml b/internal/transport/http/docs/openapi.yaml index 85aa3f0..9c1b970 100644 --- a/internal/transport/http/docs/openapi.yaml +++ b/internal/transport/http/docs/openapi.yaml @@ -93,7 +93,7 @@ info: ### 调用顺序 - 1. (可选)`POST /v1/auth/email-codes` Body `{"email"}`。测试号 `test01@spark.com`~`test10@spark.com` 可跳过,登录任意 `verify_code`。 + 1. (可选)`POST /v1/sms/codes` Header **`X-Language` 必填**(`zh-CN` / `en` / `zh-TW`),Body `{"purpose":1,"target":"邮箱或手机号"}`。服务端按格式识别邮箱/手机。测试号 `test01@spark.com`~`test10@spark.com` 可跳过发码。 2. `POST /v1/auth/login` Body 拿 `access_token` / `refresh_token`。`device_id` 由客户端生成并持久化,不要每次换。 3. 其它 HTTP:Header `Authorization: Bearer …`,可选 `X-Device-ID`、`X-Language`。 4. 续期:`POST /v1/auth/token/refresh`,**Body** `{"refresh_token"}`,不要带 Bearer。 @@ -362,6 +362,7 @@ tags: - name: files - name: favorites - name: moments + - name: square paths: /healthz: get: @@ -503,24 +504,28 @@ paths: application/json: schema: $ref: "#/components/schemas/EnumsEnvelope" - /v1/auth/email-codes: + /v1/sms/codes: post: - tags: [auth] + tags: [sms] security: [] - summary: 发送邮箱登录验证码 + summary: 发送验证码 description: | - 验证码 5 分钟有效,同一邮箱 60 秒内只能发一次。 - 测试账号 `test01@spark.com`~`test10@spark.com` 无需发码,登录时任意填写 `verify_code`。 - 当前不发送真实邮件,验证码写入 Redis(无 Redis 时为进程内存)。 - operationId: sendEmailCode + Body:`purpose` 1=登录 2=注销;`target` 为邮箱或大陆手机号(服务端识别,无需 channel)。 + **Header `X-Language` 必填**(不接受仅 Accept-Language),用于邮件正文语言;未识别时默认 `en`。 + 登录发码无需 Bearer;注销发码需 Bearer,且 `target` 须为当前绑定邮箱。 + 邮箱经 SMTP(`noreply@sparkmail.wyrs365.com`);手机走阿里云短信。 + operationId: sendVerificationCode + parameters: + - $ref: "#/components/parameters/X-Language" requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/SendEmailCodeRequest" + $ref: "#/components/schemas/SendVerificationCodeRequest" example: - email: ada@example.com + purpose: 1 + target: ada@example.com responses: "200": description: 统一 envelope @@ -528,13 +533,6 @@ paths: application/json: schema: $ref: "#/components/schemas/Envelope" - examples: - ok: - value: - code: "0" - msg: ok - data: - expires_in: 300 /v1/auth/login: post: tags: [auth] @@ -693,9 +691,16 @@ paths: summary: 注销当前账号 description: | 吊销全部设备的 JWT 并踢掉 TCP。将 `users` 行迁入 `user_delete` 后删除原行(邮箱可重新注册)。 + 非测试账号需先 `POST /v1/sms/codes`(purpose=2, channel=1)向绑定邮箱发码,再在本接口提交 `verify_code`。 同时清除好友关系、朋友圈、收藏、群成员(群主把群转给一名管理员,没有管理员则转给成员表第一位后退出;仅剩自己则解散)、会话成员、收件箱 Redis 键与 mailbox。 对方仍可见历史消息(不删 `messages`)。 operationId: deleteMe + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteAccountRequest" responses: "200": description: 统一 envelope @@ -1121,6 +1126,7 @@ paths: `group_avatar` 写入 `perm/group/{群号}/{name}.{ext}`(需 `group_id`,群主/管理员,匿名 GET); 聊天按类型:`chat/image|voice|video|gif|file/{yyyyMMdd}/{name}.{ext}`(桶生命周期按前缀过期)。 朋友圈:`moment_image` / `moment_video` / `moment_cover` 写入 `perm/moment/{image|video|cover}/{yyyyMMdd}/{name}.{ext}`(不过期,删帖/换封面时删对象)。 + 广场:`square_image` / `square_video` 写入 `perm/square/{image|video}/{yyyyMMdd}/{name}.{ext}`(不过期,删帖时删对象)。 `{name}` 为服务端 16 位 base62 随机串,不占用 Snowflake。 发图片:客户端压成 JPEG/PNG 后 `POST /v1/files/batch` 直传缩略图、压缩大图、原图(未勾选原图只传前两项)。 服务端不转码。iOS HEIC、Android WebP 须客户端先转 JPEG/PNG,两端才能互看。 @@ -1602,6 +1608,209 @@ paths: application/json: schema: $ref: "#/components/schemas/Envelope" + /v1/square/posts: + get: + tags: [square] + summary: 全站广场 Feed + description: | + `cursor` 为上一页最后一条 `post_id`,0 表示最新。仅 `visibility` 公开与不给谁看、且审核通过的帖; + 读时按拉黑、黑名单过滤。评论随帖详情返回,无单独评论列表接口。 + operationId: listSquareFeed + parameters: + - in: query + name: cursor + schema: { type: integer, format: int64, default: 0 } + - in: query + name: limit + schema: { type: integer, default: 20, maximum: 50 } + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + post: + tags: [square] + summary: 发广场帖 + description: | + 内容与朋友圈相同。媒体先 `POST /v1/files`(`square_image` / `square_video`)再填 key。 + `visibility`:0 全站公开 1 仅自己 2 白名单 3 黑名单。2/3 需 `audience_uids`(双向好友,最多 200)。 + operationId: publishSquarePost + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PublishMomentRequest" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/square/posts/{id}: + get: + tags: [square] + summary: 单条广场帖 + operationId: getSquarePost + parameters: + - $ref: "#/components/parameters/SquarePostID" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + delete: + tags: [square] + summary: 删除自己的广场帖 + operationId: deleteSquarePost + parameters: + - $ref: "#/components/parameters/SquarePostID" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/square/posts/{id}/like: + put: + tags: [square] + summary: 点赞 + operationId: likeSquarePost + parameters: + - $ref: "#/components/parameters/SquarePostID" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + delete: + tags: [square] + summary: 取消赞 + operationId: unlikeSquarePost + parameters: + - $ref: "#/components/parameters/SquarePostID" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/square/posts/{id}/comments: + post: + tags: [square] + summary: 评论或回复 + operationId: commentSquarePost + parameters: + - $ref: "#/components/parameters/SquarePostID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MomentCommentRequest" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/square/posts/{id}/comments/{cid}: + delete: + tags: [square] + summary: 删除评论 + operationId: deleteSquareComment + parameters: + - $ref: "#/components/parameters/SquarePostID" + - name: cid + in: path + required: true + schema: { type: integer, format: int64 } + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/square/users/{uid}/posts: + get: + tags: [square] + summary: 某用户广场帖 + description: 按当前浏览者与帖可见性、拉黑过滤;看自己可见私密与白名单帖。 + operationId: listSquareUserPosts + parameters: + - $ref: "#/components/parameters/FriendUID" + - in: query + name: cursor + schema: { type: integer, format: int64, default: 0 } + - in: query + name: limit + schema: { type: integer, default: 20, maximum: 50 } + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/square/notices: + get: + tags: [square] + summary: 赞评通知列表 + operationId: listSquareNotices + parameters: + - in: query + name: cursor + schema: { type: integer, format: int64, default: 0 } + - in: query + name: limit + schema: { type: integer, default: 20, maximum: 50 } + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/square/notices/unread: + get: + tags: [square] + summary: 广场互动未读角标 + operationId: squareNoticeUnread + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" + /v1/square/notices/read: + post: + tags: [square] + summary: 通知标已读 + operationId: readSquareNotices + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/MomentNoticeReadRequest" + responses: + "200": + description: 统一 envelope + content: + application/json: + schema: + $ref: "#/components/schemas/Envelope" /v1/conversations: get: tags: [conversations] @@ -1830,7 +2039,7 @@ components: description: 缺省 zh-CN UploadPurpose: type: string - enum: [avatar, group_avatar, chat_image, chat_voice, chat_video, chat_gif, chat_file, moment_image, moment_video, moment_cover] + enum: [avatar, group_avatar, chat_image, chat_voice, chat_video, chat_gif, chat_file, moment_image, moment_video, moment_cover, square_image, square_video] SystemKind: type: string enum: [friend.became, group.created, group.invite, group.leave, group.kick, group.dissolve, group.transfer] @@ -2076,13 +2285,25 @@ components: type: array items: $ref: "#/components/schemas/EnumItem" - SendEmailCodeRequest: + SendVerificationCodeRequest: type: object - required: [email] + required: [purpose, target] properties: - email: + purpose: + $ref: "#/components/schemas/VerificationPurpose" + target: type: string - format: email + description: 邮箱或大陆 11 位手机号(可带 +86) + DeleteAccountRequest: + type: object + properties: + verify_code: + type: string + description: 注销验证码(purpose=account_delete)。测试账号可省略。 + VerificationPurpose: + type: integer + enum: [1, 2] + description: 1=登录 2=注销账号 LoginRequest: type: object required: [email, verify_code, device_id] @@ -2541,6 +2762,14 @@ components: type: integer format: int64 description: 朋友圈 Snowflake post_id。 + SquarePostID: + in: path + name: id + required: true + schema: + type: integer + format: int64 + description: 广场 Snowflake post_id。 GroupID: in: path name: id diff --git a/internal/transport/http/file_test.go b/internal/transport/http/file_test.go index 47fe7c4..f9c7416 100644 --- a/internal/transport/http/file_test.go +++ b/internal/transport/http/file_test.go @@ -20,8 +20,7 @@ import ( msgentity "spark-server/internal/module/message/entity" msgmodel "spark-server/internal/module/message/model" uploadlogic "spark-server/internal/module/upload/logic" - userlogic "spark-server/internal/module/user/logic" - usermodel "spark-server/internal/module/user/model" + "spark-server/internal/module/user/logictest" "spark-server/internal/pkg/errs" ) @@ -45,7 +44,7 @@ func testFileEngine(t *testing.T) (*ginEngineBundle, *id.Generator, *msgmodel.Me if err != nil { t.Fatal(err) } - users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) + users, _ := logictest.UsersAndSMS(ids, nil) msgs := msgmodel.NewMemoryRepo() convs := convmodel.NewMemoryP2P() files := uploadlogic.NewService(&stubPresign{}, uploadlogic.Options{ diff --git a/internal/transport/http/friend_test.go b/internal/transport/http/friend_test.go index a51b21d..a403153 100644 --- a/internal/transport/http/friend_test.go +++ b/internal/transport/http/friend_test.go @@ -20,8 +20,7 @@ import ( friendlogic "spark-server/internal/module/friend/logic" friendmodel "spark-server/internal/module/friend/model" msgmodel "spark-server/internal/module/message/model" - userlogic "spark-server/internal/module/user/logic" - usermodel "spark-server/internal/module/user/model" + "spark-server/internal/module/user/logictest" "spark-server/internal/pkg/reply" ) @@ -35,7 +34,7 @@ func testFriendEngine(t *testing.T) *ginEngineBundle { if err != nil { t.Fatal(err) } - users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) + users, _ := logictest.UsersAndSMS(ids, nil) chats := convlogic.NewP2PService(convmodel.NewMemoryP2P(), ids) friends := friendlogic.NewService( friendmodel.NewMemoryRepo(), users, ids, diff --git a/internal/transport/http/group_test.go b/internal/transport/http/group_test.go index c8015c0..e92f324 100644 --- a/internal/transport/http/group_test.go +++ b/internal/transport/http/group_test.go @@ -18,8 +18,7 @@ import ( grouplogic "spark-server/internal/module/group/logic" groupmodel "spark-server/internal/module/group/model" msgmodel "spark-server/internal/module/message/model" - userlogic "spark-server/internal/module/user/logic" - usermodel "spark-server/internal/module/user/model" + "spark-server/internal/module/user/logictest" ) func testGroupEngine(t *testing.T) *ginEngineBundle { @@ -32,7 +31,7 @@ func testGroupEngine(t *testing.T) *ginEngineBundle { if err != nil { t.Fatal(err) } - users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) + users, _ := logictest.UsersAndSMS(ids, nil) friends := friendlogic.NewService( friendmodel.NewMemoryRepo(), users, ids, friendlogic.NewGreeter(convlogic.NewP2PService(convmodel.NewMemoryP2P(), ids), msgmodel.NewMemoryRepo(), ids, delivermodel.NewMemoryQueue()), diff --git a/internal/transport/http/handler/admin/users.go b/internal/transport/http/handler/admin/users.go index b716938..005be7a 100644 --- a/internal/transport/http/handler/admin/users.go +++ b/internal/transport/http/handler/admin/users.go @@ -95,10 +95,10 @@ func (h *Handler) UserByIP(c *gin.Context) { } type emailActionReq struct { - Email string `json:"email"` - Reason string `json:"reason"` - Value *bool `json:"value"` - Banned *bool `json:"banned"` + Email string `json:"email"` + Reason string `json:"reason"` + Value *bool `json:"value"` + Banned *bool `json:"banned"` Nickname string `json:"nickname"` } diff --git a/internal/transport/http/handler/auth/handler.go b/internal/transport/http/handler/auth/handler.go index 0ae1827..3fcdc4e 100644 --- a/internal/transport/http/handler/auth/handler.go +++ b/internal/transport/http/handler/auth/handler.go @@ -30,20 +30,6 @@ func (h *Handler) Login(c *gin.Context) { respond.JSON(c, tok) } -func (h *Handler) SendEmailCode(c *gin.Context) { - var in authlogic.SendCodeInput - if err := respond.BindJSON(c, &in); err != nil { - respond.JSONError(c, err) - return - } - out, err := h.login.SendEmailCode(c.Request.Context(), in) - if err != nil { - respond.JSONError(c, err) - return - } - respond.JSON(c, out) -} - func (h *Handler) Refresh(c *gin.Context) { var in authlogic.RefreshInput if err := respond.BindJSON(c, &in); err != nil { diff --git a/internal/transport/http/handler/sms/handler.go b/internal/transport/http/handler/sms/handler.go new file mode 100644 index 0000000..87c802a --- /dev/null +++ b/internal/transport/http/handler/sms/handler.go @@ -0,0 +1,63 @@ +package smshandler + +import ( + "strings" + + "github.com/gin-gonic/gin" + + smslogic "spark-server/internal/module/sms/logic" + smsentity "spark-server/internal/module/sms/entity" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/i18n" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/pkg/validate" + "spark-server/internal/transport/http/respond" +) + +const headerXLanguage = "X-Language" + +// Handler is the verification-code HTTP controller. +type Handler struct { + sms *smslogic.Service +} + +func New(sms *smslogic.Service) *Handler { + return &Handler{sms: sms} +} + +func (h *Handler) SendCode(c *gin.Context) { + if h == nil || h.sms == nil { + respond.JSONError(c, errs.ErrAvailable) + return + } + lang, ok := i18n.ParseExplicit(strings.TrimSpace(c.GetHeader(headerXLanguage))) + if !ok { + respond.JSONError(c, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(c.Request.Context()), i18n.KeySMSLanguageRequired, nil))) + return + } + if rc := reqctx.From(c.Request.Context()); rc != nil { + rc.Language = lang.String() + } + var in smslogic.SendCodeInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + if err := validate.StructLang(lang, in); err != nil { + respond.JSONError(c, err) + return + } + if in.Purpose == smsentity.PurposeAccountDelete { + rc := reqctx.From(c.Request.Context()) + if rc == nil || rc.UserID <= 0 { + respond.JSONError(c, errs.ErrUnauthorized) + return + } + } + out, err := h.sms.Send(c.Request.Context(), in) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} diff --git a/internal/transport/http/handler/square/handler.go b/internal/transport/http/handler/square/handler.go new file mode 100644 index 0000000..4674115 --- /dev/null +++ b/internal/transport/http/handler/square/handler.go @@ -0,0 +1,245 @@ +package squarehandler + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + squarelogic "spark-server/internal/module/square/logic" + "spark-server/internal/pkg/errs" + "spark-server/internal/pkg/reqctx" + "spark-server/internal/transport/http/respond" +) + +// Handler is the square HTTP controller. +type Handler struct { + square *squarelogic.Service +} + +func New(square *squarelogic.Service) *Handler { + return &Handler{square: square} +} + +func currentUID(c *gin.Context) (int64, bool) { + rc := reqctx.From(c.Request.Context()) + if rc == nil || rc.UserID <= 0 { + respond.JSONError(c, errs.ErrUnauthorized) + return 0, false + } + return rc.UserID, true +} + +func pathInt(c *gin.Context, name string) (int64, bool) { + id, err := strconv.ParseInt(c.Param(name), 10, 64) + if err != nil || id <= 0 { + respond.JSONError(c, errs.ErrInvalidArgument) + return 0, false + } + return id, true +} + +func queryInt(c *gin.Context, name string) int64 { + n, _ := strconv.ParseInt(c.Query(name), 10, 64) + return n +} + +func (h *Handler) Feed(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + out, err := h.square.Feed(c.Request.Context(), uid, queryInt(c, "cursor"), int(queryInt(c, "limit"))) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) Publish(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + var in squarelogic.PublishInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + out, err := h.square.Publish(c.Request.Context(), uid, &in) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) UserPosts(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + owner, ok := pathInt(c, "uid") + if !ok { + return + } + out, err := h.square.UserPosts(c.Request.Context(), uid, owner, queryInt(c, "cursor"), int(queryInt(c, "limit"))) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) Get(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + id, ok := pathInt(c, "id") + if !ok { + return + } + out, err := h.square.Get(c.Request.Context(), uid, id) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) Delete(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + id, ok := pathInt(c, "id") + if !ok { + return + } + if err := h.square.Delete(c.Request.Context(), uid, id); err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, gin.H{}) +} + +func (h *Handler) Like(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + id, ok := pathInt(c, "id") + if !ok { + return + } + out, err := h.square.Like(c.Request.Context(), uid, id) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) Unlike(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + id, ok := pathInt(c, "id") + if !ok { + return + } + out, err := h.square.Unlike(c.Request.Context(), uid, id) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) CreateComment(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + id, ok := pathInt(c, "id") + if !ok { + return + } + var in squarelogic.CommentInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + out, err := h.square.Comment(c.Request.Context(), uid, id, &in) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) DeleteComment(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + postID, ok := pathInt(c, "id") + if !ok { + return + } + cid, ok := pathInt(c, "cid") + if !ok { + return + } + out, err := h.square.DeleteComment(c.Request.Context(), uid, postID, cid) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) ListNotices(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + out, err := h.square.ListNotices(c.Request.Context(), uid, queryInt(c, "cursor"), int(queryInt(c, "limit"))) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) NoticeUnread(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + out, err := h.square.NoticeUnread(c.Request.Context(), uid) + if err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, out) +} + +func (h *Handler) ReadNotices(c *gin.Context) { + uid, ok := currentUID(c) + if !ok { + return + } + var in squarelogic.NoticeReadInput + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + if err := h.square.ReadNotices(c.Request.Context(), uid, &in); err != nil { + respond.JSONError(c, err) + return + } + respond.JSON(c, gin.H{}) +} diff --git a/internal/transport/http/handler/user/handler.go b/internal/transport/http/handler/user/handler.go index e8d1e44..ffbdaa8 100644 --- a/internal/transport/http/handler/user/handler.go +++ b/internal/transport/http/handler/user/handler.go @@ -57,7 +57,14 @@ func (h *Handler) DeleteMe(c *gin.Context) { respond.JSONError(c, errs.ErrUnauthorized) return } - if err := h.users.DeleteAccount(c.Request.Context(), rc.UserID); err != nil { + var in userlogic.DeleteAccountInput + if c.Request.ContentLength > 0 { + if err := respond.BindJSON(c, &in); err != nil { + respond.JSONError(c, err) + return + } + } + if err := h.users.DeleteAccount(c.Request.Context(), rc.UserID, in); err != nil { respond.JSONError(c, err) return } diff --git a/internal/transport/http/inbox_test.go b/internal/transport/http/inbox_test.go index c48083d..0a8568b 100644 --- a/internal/transport/http/inbox_test.go +++ b/internal/transport/http/inbox_test.go @@ -22,8 +22,7 @@ import ( msgmodel "spark-server/internal/module/message/model" synclogic "spark-server/internal/module/sync/logic" syncmodel "spark-server/internal/module/sync/model" - userlogic "spark-server/internal/module/user/logic" - usermodel "spark-server/internal/module/user/model" + "spark-server/internal/module/user/logictest" ) func TestInboxIncrementalHTTP(t *testing.T) { @@ -35,7 +34,7 @@ func TestInboxIncrementalHTTP(t *testing.T) { if err != nil { t.Fatal(err) } - users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) + users, _ := logictest.UsersAndSMS(ids, nil) chats := convmodel.NewMemoryP2P() p2p := convlogic.NewP2PService(chats, ids) q := delivermodel.NewMemoryQueue() diff --git a/internal/transport/http/message_test.go b/internal/transport/http/message_test.go index 355fe77..22cad1e 100644 --- a/internal/transport/http/message_test.go +++ b/internal/transport/http/message_test.go @@ -17,8 +17,7 @@ import ( friendmodel "spark-server/internal/module/friend/model" msglogic "spark-server/internal/module/message/logic" msgmodel "spark-server/internal/module/message/model" - userlogic "spark-server/internal/module/user/logic" - usermodel "spark-server/internal/module/user/model" + "spark-server/internal/module/user/logictest" ) func testMessageEngine(t *testing.T) http.Handler { @@ -31,7 +30,7 @@ func testMessageEngine(t *testing.T) http.Handler { if err != nil { t.Fatal(err) } - users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) + users, _ := logictest.UsersAndSMS(ids, nil) chats := convmodel.NewMemoryP2P() p2p := convlogic.NewP2PService(chats, ids) q := delivermodel.NewMemoryQueue() diff --git a/internal/transport/http/meta_test.go b/internal/transport/http/meta_test.go index ba30ebd..31f1cb9 100644 --- a/internal/transport/http/meta_test.go +++ b/internal/transport/http/meta_test.go @@ -26,12 +26,27 @@ func TestMetaEnumsPublic(t *testing.T) { if err := json.Unmarshal(env.Data, &cat); err != nil { t.Fatal(err) } - if len(cat.Gender) != 3 || cat.Gender[1].Name != "male" || cat.Gender[1].Label != "男" { + if len(cat.Gender) != 3 || cat.Gender[1].Name != "male" || cat.Gender[1].Label != "Male" { t.Fatalf("gender %+v", cat.Gender) } if len(cat.MessageType) != 9 || len(cat.TCPCommand) != 15 || cat.Language[0].Value != "zh-CN" { t.Fatalf("catalog %+v", cat) } + zhReq := httptest.NewRequest(http.MethodGet, "/v1/meta/enums", nil) + zhReq.Header.Set("X-Language", "zh-CN") + zhW := httptest.NewRecorder() + r.ServeHTTP(zhW, zhReq) + var zhEnv reply.Envelope + if err := json.Unmarshal(zhW.Body.Bytes(), &zhEnv); err != nil { + t.Fatal(err) + } + var zhCat entity.Catalog + if err := json.Unmarshal(zhEnv.Data, &zhCat); err != nil { + t.Fatal(err) + } + if zhCat.Gender[1].Label != "男" { + t.Fatalf("zh label %+v", zhCat.Gender) + } enReq := httptest.NewRequest(http.MethodGet, "/v1/meta/enums", nil) enReq.Header.Set("X-Language", "en") enW := httptest.NewRecorder() diff --git a/internal/transport/http/moment_test.go b/internal/transport/http/moment_test.go index c2edf10..8df9193 100644 --- a/internal/transport/http/moment_test.go +++ b/internal/transport/http/moment_test.go @@ -19,8 +19,7 @@ import ( momentlogic "spark-server/internal/module/moment/logic" momentmodel "spark-server/internal/module/moment/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/module/user/logictest" "spark-server/internal/pkg/errs" ) @@ -34,7 +33,7 @@ func testMomentEngine(t *testing.T) *ginEngineBundle { if err != nil { t.Fatal(err) } - users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) + users, _ := logictest.UsersAndSMS(ids, nil) friends := friendlogic.NewService( friendmodel.NewMemoryRepo(), users, ids, friendlogic.NewGreeter(convlogic.NewP2PService(convmodel.NewMemoryP2P(), ids), msgmodel.NewMemoryRepo(), ids, delivermodel.NewMemoryQueue()), diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go index cc5bbd6..5392645 100644 --- a/internal/transport/http/router.go +++ b/internal/transport/http/router.go @@ -18,6 +18,8 @@ import ( msghandler "spark-server/internal/transport/http/handler/message" metahandler "spark-server/internal/transport/http/handler/meta" momenthandler "spark-server/internal/transport/http/handler/moment" + squarehandler "spark-server/internal/transport/http/handler/square" + smshandler "spark-server/internal/transport/http/handler/sms" userhandler "spark-server/internal/transport/http/handler/user" "spark-server/internal/transport/http/respond" ) @@ -29,12 +31,14 @@ func registerRoutes(r *gin.Engine, deps *EngineDeps) { v1 := r.Group("/v1") registerMetaRoutes(v1) registerAuthRoutes(v1, deps) + registerSMSRoutes(v1, deps) registerUserRoutes(v1, deps) registerFriendRoutes(v1, deps) registerGroupRoutes(v1, deps) registerFileRoutes(v1, deps) registerFavoriteRoutes(v1, deps) registerMomentRoutes(v1, deps) + registerSquareRoutes(v1, deps) registerConversationRoutes(v1, deps) registerMessageRoutes(v1, deps) } @@ -74,11 +78,19 @@ func registerAuthRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { } h := authhandler.New(deps.Login) g.POST("/login", h.Login) - g.POST("/email-codes", h.SendEmailCode) g.POST("/token/refresh", h.Refresh) g.POST("/logout", h.Logout) } +func registerSMSRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { + if deps.SMS == nil { + return + } + h := smshandler.New(deps.SMS) + g := v1.Group("/sms") + g.POST("/codes", h.SendCode) +} + func registerUserRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { if deps.Users == nil { return @@ -177,6 +189,26 @@ func registerMomentRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { g.DELETE("/:id/comments/:cid", h.DeleteComment) } +func registerSquareRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { + if deps.Square == nil { + return + } + h := squarehandler.New(deps.Square) + g := v1.Group("/square") + g.GET("/posts", h.Feed) + g.POST("/posts", h.Publish) + g.GET("/posts/:id", h.Get) + g.DELETE("/posts/:id", h.Delete) + g.PUT("/posts/:id/like", h.Like) + g.DELETE("/posts/:id/like", h.Unlike) + g.POST("/posts/:id/comments", h.CreateComment) + g.DELETE("/posts/:id/comments/:cid", h.DeleteComment) + g.GET("/users/:uid/posts", h.UserPosts) + g.GET("/notices", h.ListNotices) + g.GET("/notices/unread", h.NoticeUnread) + g.POST("/notices/read", h.ReadNotices) +} + func registerConversationRoutes(v1 *gin.RouterGroup, deps *EngineDeps) { if deps.Sync == nil { return diff --git a/internal/transport/http/server.go b/internal/transport/http/server.go index e9f2bd1..1a65209 100644 --- a/internal/transport/http/server.go +++ b/internal/transport/http/server.go @@ -19,6 +19,8 @@ import ( grouplogic "spark-server/internal/module/group/logic" msglogic "spark-server/internal/module/message/logic" momentlogic "spark-server/internal/module/moment/logic" + squarelogic "spark-server/internal/module/square/logic" + smslogic "spark-server/internal/module/sms/logic" synclogic "spark-server/internal/module/sync/logic" uploadlogic "spark-server/internal/module/upload/logic" userlogic "spark-server/internal/module/user/logic" @@ -40,20 +42,22 @@ type Health interface { // EngineDeps wires Application services into Gin. Auth nil disables Bearer checks (tests). type EngineDeps struct { - Auth authlogic.Service - Login *authlogic.LoginService - Users *userlogic.Service - Friends *friendlogic.Service - Groups *grouplogic.Service - Files *uploadlogic.Service - Favorites *favlogic.Service - Moments *momentlogic.Service - Messages *msglogic.Service - Sync *synclogic.Service - Admin *adminlogic.Service - AdminTokens *adminlogic.TokenService - Health Health - AuthLimiter *ratelimit.Limiter + Auth authlogic.Service + Login *authlogic.LoginService + SMS *smslogic.Service + Users *userlogic.Service + Friends *friendlogic.Service + Groups *grouplogic.Service + Files *uploadlogic.Service + Favorites *favlogic.Service + Moments *momentlogic.Service + Square *squarelogic.Service + Messages *msglogic.Service + Sync *synclogic.Service + Admin *adminlogic.Service + AdminTokens *adminlogic.TokenService + Health Health + AuthLimiter *ratelimit.Limiter } func New(log *zap.Logger, addr string, deps *EngineDeps) *Server { diff --git a/internal/transport/http/server_test.go b/internal/transport/http/server_test.go index c46f766..bb868cb 100644 --- a/internal/transport/http/server_test.go +++ b/internal/transport/http/server_test.go @@ -16,8 +16,7 @@ import ( "spark-server/internal/infrastructure/logger" authentity "spark-server/internal/module/auth/entity" authlogic "spark-server/internal/module/auth/logic" - userlogic "spark-server/internal/module/user/logic" - usermodel "spark-server/internal/module/user/model" + "spark-server/internal/module/user/logictest" "spark-server/internal/pkg/errs" "spark-server/internal/pkg/ratelimit" "spark-server/internal/pkg/reply" @@ -150,11 +149,12 @@ func testLoginEngine(t *testing.T) *gin.Engine { if err != nil { t.Fatal(err) } - users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) + users, smsSvc := logictest.UsersAndSMS(ids, nil) users.BindAccountCleanup(j, nil) return NewEngine(zap.NewNop(), &EngineDeps{ Auth: j, Login: authlogic.NewLoginService(j, users), + SMS: smsSvc, Users: users, }) } @@ -430,10 +430,22 @@ func TestLoginRejectsBadInput(t *testing.T) { if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { t.Fatal(err) } - if env.Code != errs.ErrInvalidArgument.Code || !strings.Contains(env.Msg, "请填写验证码") { + if env.Code != errs.ErrInvalidArgument.Code || !strings.Contains(env.Msg, "Please enter the verification code") { t.Fatalf("%+v", env) } + w = httptest.NewRecorder() + zhReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com"}`)) + zhReq.Header.Set("Content-Type", "application/json") + zhReq.Header.Set("X-Language", "zh-CN") + r.ServeHTTP(w, zhReq) + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatal(err) + } + if env.Code != errs.ErrInvalidArgument.Code || !strings.Contains(env.Msg, "请填写验证码") { + t.Fatalf("zh %+v", env) + } + w = httptest.NewRecorder() enReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com"}`)) enReq.Header.Set("Content-Type", "application/json") @@ -492,10 +504,11 @@ func TestLoginRateLimitedByRealIP(t *testing.T) { if err != nil { t.Fatal(err) } - users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) + users, smsSvc := logictest.UsersAndSMS(ids, nil) r := NewEngine(zap.NewNop(), &EngineDeps{ Auth: j, Login: authlogic.NewLoginService(j, users), + SMS: smsSvc, Users: users, AuthLimiter: ratelimit.New(1), }) diff --git a/internal/transport/tcp/session/conn.go b/internal/transport/tcp/session/conn.go index 700a9ed..de287d8 100644 --- a/internal/transport/tcp/session/conn.go +++ b/internal/transport/tcp/session/conn.go @@ -32,10 +32,10 @@ type Conn struct { closed atomic.Bool requestID string - outbound chan *Packet - write func([]byte) error - closeFn func() error - wake func() + outbound chan *Packet + write func([]byte) error + closeFn func() error + wake func() } func NewConn(id, remote string, outboundSize int) *Conn { diff --git a/migrations/0018_square.sql b/migrations/0018_square.sql new file mode 100644 index 0000000..091f660 --- /dev/null +++ b/migrations/0018_square.sql @@ -0,0 +1,66 @@ +-- 广场。Snowflake post_id;content 为 VARBINARY 序列化 JSON 字节。 + +CREATE TABLE IF NOT EXISTS square_posts ( + id BIGINT PRIMARY KEY COMMENT 'Snowflake 帖 id,Feed 游标', + user_id BIGINT NOT NULL COMMENT '作者 users.id', + kind SMALLINT NOT NULL COMMENT '1纯文 2图 3视频', + text VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '正文,可空;与媒体至少有一项', + content VARBINARY(16384) NOT NULL COMMENT '序列化 JSON 字节(json.Marshal),图/视频只存 object_key', + poi_lat DOUBLE NOT NULL DEFAULT 0 COMMENT '地点纬度', + poi_lng DOUBLE NOT NULL DEFAULT 0 COMMENT '地点经度', + poi_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '地点名称', + poi_address VARCHAR(256) NOT NULL DEFAULT '' COMMENT '地点地址', + visibility SMALLINT NOT NULL DEFAULT 0 COMMENT '0全站公开 1仅自己 2白名单 3黑名单', + audit_status SMALLINT NOT NULL DEFAULT 1 COMMENT '0待审 1通过 2拒绝;v1 发帖为 1', + audit_note VARCHAR(256) NOT NULL DEFAULT '' COMMENT '拒绝原因', + audited_at DATETIME(3) NULL COMMENT '审核时间', + status SMALLINT NOT NULL DEFAULT 0 COMMENT '0正常 1已删', + like_count INT NOT NULL DEFAULT 0 COMMENT '点赞数', + comment_count INT NOT NULL DEFAULT 0 COMMENT '未删评论数', + created_at DATETIME(3) NOT NULL COMMENT '发帖时间', + KEY idx_public_feed (audit_status, visibility, id), + KEY idx_user (user_id, id) +) COMMENT='广场帖'; + +CREATE TABLE IF NOT EXISTS square_audience ( + post_id BIGINT NOT NULL COMMENT '帖 id', + peer_id BIGINT NOT NULL COMMENT '白名单能看 / 黑名单不能看 的好友 uid', + PRIMARY KEY (post_id, peer_id) +) COMMENT='广场部分可见名单'; + +CREATE TABLE IF NOT EXISTS square_likes ( + post_id BIGINT NOT NULL COMMENT '帖 id', + user_id BIGINT NOT NULL COMMENT '点赞人', + created_at DATETIME(3) NOT NULL COMMENT '点赞时间', + PRIMARY KEY (post_id, user_id) +) COMMENT='广场点赞'; + +CREATE TABLE IF NOT EXISTS square_comments ( + id BIGINT PRIMARY KEY COMMENT 'Snowflake 评论 id', + post_id BIGINT NOT NULL COMMENT '帖 id', + user_id BIGINT NOT NULL COMMENT '评论人', + reply_to_id BIGINT NOT NULL DEFAULT 0 COMMENT '父评论 id,0 表示直接评帖', + reply_to_uid BIGINT NOT NULL DEFAULT 0 COMMENT '被回复用户', + text VARCHAR(500) NOT NULL COMMENT '评论文本', + status SMALLINT NOT NULL DEFAULT 0 COMMENT '0正常 1已删', + created_at DATETIME(3) NOT NULL COMMENT '评论时间', + KEY idx_post (post_id, id) +) COMMENT='广场评论'; + +CREATE TABLE IF NOT EXISTS square_notices ( + id BIGINT PRIMARY KEY COMMENT 'Snowflake 通知 id', + to_user_id BIGINT NOT NULL COMMENT '接收人', + from_user_id BIGINT NOT NULL COMMENT '动作发起人', + kind SMALLINT NOT NULL COMMENT '1赞 2评论 3回复', + post_id BIGINT NOT NULL COMMENT '帖 id', + comment_id BIGINT NOT NULL DEFAULT 0 COMMENT '评论 id,赞为 0', + read_at DATETIME(3) NULL COMMENT '已读时间', + created_at DATETIME(3) NOT NULL COMMENT '通知时间', + KEY idx_to_user (to_user_id, id) +) COMMENT='广场赞评通知'; + +CREATE TABLE IF NOT EXISTS square_notice_state ( + user_id BIGINT PRIMARY KEY COMMENT '用户 users.id', + last_read_notice_id BIGINT NOT NULL DEFAULT 0 COMMENT '互动通知已读水位', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间' +) COMMENT='广场通知未读水位';