feat: 1、square 2、smtp
This commit is contained in:
parent
94dd2d819c
commit
edec7d82e9
17
.cursor/rules/verify-before-done.mdc
Normal file
17
.cursor/rules/verify-before-done.mdc
Normal file
@ -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.
|
||||||
@ -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).
|
- 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.
|
- 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.
|
- 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
|
## Additional resources
|
||||||
|
|
||||||
|
|||||||
@ -79,7 +79,8 @@ Public HTTP is `/v1/{resource}/...`. Register with Gin groups in `internal/trans
|
|||||||
| Group | Prefix | Examples |
|
| Group | Prefix | Examples |
|
||||||
|-------|--------|----------|
|
|-------|--------|----------|
|
||||||
| system | `/healthz`, `/readyz`, `/docs` | liveness, readiness, OpenAPI(`/docs` API,`/docs/enums` 枚举,`/docs/errors` 错误码) |
|
| 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` |
|
| 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=` |
|
| 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 |
|
| 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.
|
- 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`.
|
- 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`.
|
- 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
|
## Checklist when adding an HTTP API
|
||||||
|
|||||||
@ -4,14 +4,16 @@ User-visible **error `msg`** and **notification copy** are never hardcoded in ha
|
|||||||
|
|
||||||
## Language on every request
|
## 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 |
|
| Transport | Client sends | Server stores |
|
||||||
|-----------|--------------|----------------|
|
|-----------|--------------|----------------|
|
||||||
| HTTP | `X-Language` if set, else `Accept-Language` | Trace middleware, every request |
|
| 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` |
|
| 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.
|
Do not put language in JWT. Rate limit still uses `reqctx.RealIP`, not language.
|
||||||
|
|
||||||
|
|||||||
@ -9,5 +9,6 @@ Before writing code:
|
|||||||
3. Keep functions within `.golangci.yaml` (`funlen` 100, `lll` 240)
|
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.
|
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.
|
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.
|
Do not introduce Kafka, K8s, microservices, ConversationSeq, application-layer handshake crypto, or public gnet ports.
|
||||||
|
|||||||
5
Makefile
5
Makefile
@ -8,7 +8,7 @@ LINT ?= golangci-lint
|
|||||||
CONFIG ?= configs/config.yaml
|
CONFIG ?= configs/config.yaml
|
||||||
LOGIC_COVER_MIN ?= 65
|
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
|
all: lint test build
|
||||||
|
|
||||||
@ -68,3 +68,6 @@ run: build
|
|||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
bash deploy/docker/deploy.sh
|
bash deploy/docker/deploy.sh
|
||||||
|
|
||||||
|
deploy-rollback:
|
||||||
|
bash deploy/docker/rollback.sh
|
||||||
|
|||||||
@ -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`。
|
2. `POST /v1/auth/login` 拿 `access_token` / `refresh_token`。`device_id` 由**客户端生成并持久化**,服务端不分配;写入 JWT `did`。
|
||||||
3. 其它 HTTP:`Authorization: Bearer …`,可选 `X-Device-ID`(须等于登录时的 device_id)。
|
3. 其它 HTTP:`Authorization: Bearer …`,可选 `X-Device-ID`(须等于登录时的 device_id)。
|
||||||
4. 续期:`POST /v1/auth/token/refresh`,Body `{"refresh_token"}`,不要带 Bearer。
|
4. 续期:`POST /v1/auth/token/refresh`,Body `{"refresh_token"}`,不要带 Bearer。
|
||||||
|
|||||||
@ -96,3 +96,24 @@ object_storage:
|
|||||||
use_ssl: false
|
use_ssl: false
|
||||||
presign_ttl: 15m
|
presign_ttl: 15m
|
||||||
chat_expire_days: 365
|
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 <noreply@sparkmail.wyrs365.com>"
|
||||||
|
aliyun_sms:
|
||||||
|
access_key_id: ""
|
||||||
|
access_key_secret: ""
|
||||||
|
sign_name: ""
|
||||||
|
templates:
|
||||||
|
login:
|
||||||
|
mobile: ""
|
||||||
|
unicom: ""
|
||||||
|
telecom: ""
|
||||||
|
account_delete:
|
||||||
|
mobile: ""
|
||||||
|
unicom: ""
|
||||||
|
telecom: ""
|
||||||
|
|||||||
68
deploy/docker/README.md
Normal file
68
deploy/docker/README.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# ECS 部署与回滚
|
||||||
|
|
||||||
|
## 目录(主机 `/data/spark-server`)
|
||||||
|
|
||||||
|
| 路径 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `compose/docker-compose.yaml` | 服务定义 |
|
||||||
|
| `configs/config.yaml` | 运行配置(部署前会备份到 `configs/backup/`) |
|
||||||
|
| `images/spark-server-<release>.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:<git-sha>-<时间>` 与 `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-<release>.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
|
||||||
|
```
|
||||||
@ -96,3 +96,24 @@ object_storage:
|
|||||||
use_ssl: false
|
use_ssl: false
|
||||||
presign_ttl: 15m
|
presign_ttl: 15m
|
||||||
chat_expire_days: 365
|
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 <noreply@sparkmail.wyrs365.com>"
|
||||||
|
aliyun_sms:
|
||||||
|
access_key_id: ""
|
||||||
|
access_key_secret: ""
|
||||||
|
sign_name: ""
|
||||||
|
templates:
|
||||||
|
login:
|
||||||
|
mobile: ""
|
||||||
|
unicom: ""
|
||||||
|
telecom: ""
|
||||||
|
account_delete:
|
||||||
|
mobile: ""
|
||||||
|
unicom: ""
|
||||||
|
telecom: ""
|
||||||
|
|||||||
@ -1,54 +1,66 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Build linux/amd64 binaries + runtime image locally, then load them on the ECS host.
|
# Build linux/amd64 binaries + runtime image locally, deploy to ECS, with rollback on health failure.
|
||||||
# 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
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
HOST="${SPARK_SSH_HOST:-root@47.236.66.9}"
|
# shellcheck source=lib.sh
|
||||||
KEY="${SPARK_SSH_KEY:-$HOME/.ssh/spark-ecs}"
|
source "$DIR/lib.sh"
|
||||||
DATA="${SPARK_DATA_DIR:-/data/spark-server}"
|
deploy_lib_init
|
||||||
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)
|
|
||||||
|
|
||||||
need() {
|
ROOT="$ROOT"
|
||||||
command -v "$1" >/dev/null || {
|
RELEASE="$(deploy_lib_release "$ROOT")"
|
||||||
echo "need $1 on this machine" >&2
|
IMAGE_VERSIONED="${IMAGE_BASE}:${RELEASE}"
|
||||||
exit 1
|
TAR_VERSIONED="$ROOT/bin/spark-server-${RELEASE}.tar.gz"
|
||||||
}
|
TAR_LATEST="$ROOT/bin/spark-server.tar.gz"
|
||||||
}
|
|
||||||
|
|
||||||
need go
|
deploy_lib_need_tools
|
||||||
need docker
|
|
||||||
need rsync
|
|
||||||
need ssh
|
|
||||||
need scp
|
|
||||||
|
|
||||||
|
echo "==> release $RELEASE"
|
||||||
echo "==> local linux/amd64 binaries + image"
|
echo "==> local linux/amd64 binaries + image"
|
||||||
make -C "$ROOT" image
|
make -C "$ROOT" image IMAGE="$IMAGE_LATEST"
|
||||||
mkdir -p "$(dirname "$TAR")"
|
docker tag "$IMAGE_LATEST" "$IMAGE_VERSIONED"
|
||||||
docker save "$IMAGE" | gzip -c >"$TAR"
|
mkdir -p "$(dirname "$TAR_VERSIONED")"
|
||||||
echo "image $(docker image inspect "$IMAGE" --format '{{.Size}}') bytes, tar $(wc -c <"$TAR") bytes"
|
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"
|
echo "==> upload (config on host is never overwritten unless SPARK_UPLOAD_CONFIG=1)"
|
||||||
"${SSH[@]}" "mkdir -p $DATA/compose $DATA/configs $DATA/logs $DATA/images"
|
"${SSH[@]}" "mkdir -p $DATA/compose $DATA/configs $DATA/logs $DATA/images $DATA/releases"
|
||||||
"${SCP[@]}" "$TAR" "$HOST:$DATA/images/spark-server.tar.gz"
|
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/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" \
|
rsync -az -e "ssh -i $KEY -o BatchMode=yes" \
|
||||||
"$ROOT/deploy/nginx/host/" "$HOST:/tmp/spark-nginx-host/"
|
"$ROOT/deploy/nginx/host/" "$HOST:/tmp/spark-nginx-host/"
|
||||||
|
|
||||||
echo "==> load and start"
|
echo "==> load, migrate, start"
|
||||||
"${SSH[@]}" "chown -R 10001:10001 $DATA/logs && docker load -i $DATA/images/spark-server.tar.gz"
|
deploy_lib_remote_load_and_up "$DATA" "$DATA/images/$(basename "$TAR_VERSIONED")" 1 "$RELEASE"
|
||||||
# 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"
|
echo "==> nginx reload"
|
||||||
"${SSH[@]}" "cd $DATA/compose && docker compose -f docker-compose.yaml up -d --force-recreate spark-server"
|
deploy_lib_remote_nginx_reload
|
||||||
"${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"
|
echo "==> health check"
|
||||||
"${SSH[@]}" "docker ps --filter name=spark-server --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
|
if deploy_lib_remote_health 12 5; then
|
||||||
"${SSH[@]}" "curl -fsS http://127.0.0.1:18080/healthz && echo && curl -fsS http://127.0.0.1:18080/readyz && echo"
|
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
|
||||||
|
|||||||
160
deploy/docker/lib.sh
Executable file
160
deploy/docker/lib.sh
Executable file
@ -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" <<JSON
|
||||||
|
{
|
||||||
|
"status": "$STATUS",
|
||||||
|
"release": "$RELEASE",
|
||||||
|
"image_id": "$ID",
|
||||||
|
"previous_image_id": "$PREV_ID",
|
||||||
|
"image_tar": "$DATA/images/$TAR_NAME",
|
||||||
|
"deployed_at": "$(date -Iseconds)"
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
EOS
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy_lib_remote_nginx_reload() {
|
||||||
|
"${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"
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy_lib_remote_ps() {
|
||||||
|
"${SSH[@]}" "docker ps --filter name=spark-server --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
|
||||||
|
}
|
||||||
26
deploy/docker/rollback.sh
Executable file
26
deploy/docker/rollback.sh
Executable file
@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Roll back spark-server on ECS to the image tagged spark-server:rollback (stamped before last deploy).
|
||||||
|
# Does not reverse MySQL migrations — only the app container. See deploy/docker/README.md.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
# shellcheck source=lib.sh
|
||||||
|
source "$DIR/lib.sh"
|
||||||
|
deploy_lib_init
|
||||||
|
|
||||||
|
deploy_lib_need_tools
|
||||||
|
|
||||||
|
echo "==> 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"
|
||||||
400
docs/square.md
Normal file
400
docs/square.md
Normal file
@ -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`
|
||||||
@ -39,6 +39,9 @@ import (
|
|||||||
msgmodel "spark-server/internal/module/message/model"
|
msgmodel "spark-server/internal/module/message/model"
|
||||||
momentlogic "spark-server/internal/module/moment/logic"
|
momentlogic "spark-server/internal/module/moment/logic"
|
||||||
momentmodel "spark-server/internal/module/moment/model"
|
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"
|
synclogic "spark-server/internal/module/sync/logic"
|
||||||
syncmodel "spark-server/internal/module/sync/model"
|
syncmodel "spark-server/internal/module/sync/model"
|
||||||
uploadlogic "spark-server/internal/module/upload/logic"
|
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}
|
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()}
|
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 := authlogic.NewLoginService(jwtSvc, users)
|
||||||
login.Sessions = sessions
|
login.Sessions = sessions
|
||||||
var deliverQueue deliverlogic.Queue
|
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)
|
files := wireFiles(cfg, objects, groups)
|
||||||
favorites := wireFavorites(idgen, db, objects, chat)
|
favorites := wireFavorites(idgen, db, objects, chat)
|
||||||
moments := wireMoments(idgen, db, objects, friends, users)
|
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)
|
adminSvc, adminTokens := wireAdmin(cfg, db, groups, idgen)
|
||||||
app.tcp = tcp.NewServer(log, pool, sessions, &tcp.Options{
|
app.tcp = tcp.NewServer(log, pool, sessions, &tcp.Options{
|
||||||
Addr: cfg.Server.TCP.Addr,
|
Addr: cfg.Server.TCP.Addr,
|
||||||
@ -195,25 +201,38 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge
|
|||||||
Messages: messages,
|
Messages: messages,
|
||||||
Sync: syncSvc,
|
Sync: syncSvc,
|
||||||
})
|
})
|
||||||
app.http = httpx.New(log, cfg.Server.HTTP.Addr, &httpx.EngineDeps{
|
app.http = httpx.New(log, cfg.Server.HTTP.Addr, httpEngineDeps(
|
||||||
Auth: jwtSvc,
|
jwtSvc, login, smsSvc, users, friends, groups, files, favorites, moments, square, messages, syncSvc, adminSvc, adminTokens, probe, authLimit,
|
||||||
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,
|
|
||||||
})
|
|
||||||
return app, nil
|
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) {
|
func wireAdmin(cfg *Config, db *mysql.DB, groups *grouplogic.Service, idgen *id.Generator) (*adminlogic.Service, *adminlogic.TokenService) {
|
||||||
tokens := adminlogic.NewTokenService(cfg.Admin.JWTSecret)
|
tokens := adminlogic.NewTokenService(cfg.Admin.JWTSecret)
|
||||||
var repo *adminmodel.Repo
|
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
|
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 {
|
func wireUserRepo(db *mysql.DB) userlogic.Repository {
|
||||||
var repo userlogic.Repository
|
|
||||||
if db != nil && db.Ready() {
|
if db != nil && db.Ready() {
|
||||||
repo = usermodel.NewRepo(db.Gorm())
|
return usermodel.NewRepo(db.Gorm())
|
||||||
} else {
|
|
||||||
repo = usermodel.NewMemoryRepo()
|
|
||||||
}
|
}
|
||||||
var codes userlogic.CodeStore
|
return usermodel.NewMemoryRepo()
|
||||||
if rdb != nil && rdb.Ready() {
|
}
|
||||||
codes = usermodel.NewRedisCodes(rdb.RDB())
|
|
||||||
} else {
|
func wireUserObjects(store *objectstore.Store) userlogic.Objects {
|
||||||
codes = usermodel.NewMemoryCodes()
|
|
||||||
}
|
|
||||||
var objects userlogic.Objects
|
|
||||||
if store != nil && store.Ready() {
|
if store != nil && store.Ready() {
|
||||||
objects = store
|
return store
|
||||||
}
|
}
|
||||||
return userlogic.NewService(repo, codes, idgen, objects)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type chatStores struct {
|
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)
|
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 {
|
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 {
|
if idgen == nil || friends == nil || users == nil {
|
||||||
return nil
|
return nil
|
||||||
@ -384,6 +414,7 @@ func wireMessages(idgen *id.Generator, friends *friendlogic.Service, groups *gro
|
|||||||
func accountCleaners(
|
func accountCleaners(
|
||||||
friends *friendlogic.Service,
|
friends *friendlogic.Service,
|
||||||
moments *momentlogic.Service,
|
moments *momentlogic.Service,
|
||||||
|
square *squarelogic.Service,
|
||||||
groups *grouplogic.Service,
|
groups *grouplogic.Service,
|
||||||
favorites *favlogic.Service,
|
favorites *favlogic.Service,
|
||||||
inbox inboxlogic.Store,
|
inbox inboxlogic.Store,
|
||||||
@ -397,6 +428,9 @@ func accountCleaners(
|
|||||||
if moments != nil {
|
if moments != nil {
|
||||||
out = append(out, moments)
|
out = append(out, moments)
|
||||||
}
|
}
|
||||||
|
if square != nil {
|
||||||
|
out = append(out, square)
|
||||||
|
}
|
||||||
if groups != nil {
|
if groups != nil {
|
||||||
out = append(out, groups)
|
out = append(out, groups)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,7 @@ type Config struct {
|
|||||||
RateLimit RateLimitConfig `yaml:"rate_limit"`
|
RateLimit RateLimitConfig `yaml:"rate_limit"`
|
||||||
Message MessageConfig `yaml:"message"`
|
Message MessageConfig `yaml:"message"`
|
||||||
ObjectStorage ObjectStorageConfig `yaml:"object_storage"`
|
ObjectStorage ObjectStorageConfig `yaml:"object_storage"`
|
||||||
|
SMS SMSConfig `yaml:"sms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminConfig is popadmin JWT validation (separate from client jwt).
|
// AdminConfig is popadmin JWT validation (separate from client jwt).
|
||||||
@ -140,6 +141,37 @@ type MessageConfig struct {
|
|||||||
MsgIDExpireMS int64 `yaml:"msgid_expire_ms"`
|
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 {
|
type JWTConfig struct {
|
||||||
Secret string `yaml:"secret"`
|
Secret string `yaml:"secret"`
|
||||||
Issuer string `yaml:"issuer"`
|
Issuer string `yaml:"issuer"`
|
||||||
@ -254,6 +286,15 @@ func applyDefaults(cfg *Config) {
|
|||||||
if v := os.Getenv("SPARK_S3_BUCKET"); v != "" {
|
if v := os.Getenv("SPARK_S3_BUCKET"); v != "" {
|
||||||
cfg.ObjectStorage.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 == "" {
|
if cfg.ObjectStorage.Bucket == "" {
|
||||||
cfg.ObjectStorage.Bucket = "spark"
|
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")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,7 @@ func TestParseEnvAndProductionValidate(t *testing.T) {
|
|||||||
t.Fatal("prod default admin jwt")
|
t.Fatal("prod default admin jwt")
|
||||||
}
|
}
|
||||||
prod.Admin.JWTSecret = "production-admin-jwt-secret-not-default"
|
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 {
|
if err := prod.normalize(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
69
internal/bootstrap/sms.go
Normal file
69
internal/bootstrap/sms.go
Normal file
@ -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,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
183
internal/infrastructure/sms/aliyun.go
Normal file
183
internal/infrastructure/sms/aliyun.go
Normal file
@ -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)
|
||||||
140
internal/infrastructure/smtp/mailer.go
Normal file
140
internal/infrastructure/smtp/mailer.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -5,7 +5,6 @@ import (
|
|||||||
|
|
||||||
authentity "spark-server/internal/module/auth/entity"
|
authentity "spark-server/internal/module/auth/entity"
|
||||||
userentity "spark-server/internal/module/user/entity"
|
userentity "spark-server/internal/module/user/entity"
|
||||||
userlogic "spark-server/internal/module/user/logic"
|
|
||||||
"spark-server/internal/pkg/errs"
|
"spark-server/internal/pkg/errs"
|
||||||
"spark-server/internal/pkg/i18n"
|
"spark-server/internal/pkg/i18n"
|
||||||
"spark-server/internal/pkg/reqctx"
|
"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.
|
// Verifier checks email OTP and records the login device. The user module owns the implementation.
|
||||||
type Verifier interface {
|
type Verifier interface {
|
||||||
AuthenticateEmail(ctx context.Context, email, verifyCode string, device *userentity.LoginDevice) (*userentity.User, error)
|
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.
|
// 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"`
|
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 {
|
type RefreshInput struct {
|
||||||
RefreshToken string `json:"refresh_token" validate:"required" label:"field.refresh_token" errmsg:"required:validate.login.refresh_required"`
|
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
|
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) {
|
func (s *LoginService) Refresh(ctx context.Context, in RefreshInput) (*authentity.IssuedToken, error) {
|
||||||
if s == nil || s.Issuer == nil {
|
if s == nil || s.Issuer == nil {
|
||||||
return nil, errs.ErrUnauthorized
|
return nil, errs.ErrUnauthorized
|
||||||
|
|||||||
@ -5,6 +5,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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"
|
userlogic "spark-server/internal/module/user/logic"
|
||||||
usermodel "spark-server/internal/module/user/model"
|
usermodel "spark-server/internal/module/user/model"
|
||||||
"spark-server/internal/pkg/errs"
|
"spark-server/internal/pkg/errs"
|
||||||
@ -21,7 +24,7 @@ func TestLoginServiceValidatesAndIssues(t *testing.T) {
|
|||||||
|
|
||||||
_, err = svc.Login(ctx, &LoginInput{Email: "test01@spark.com"})
|
_, err = svc.Login(ctx, &LoginInput{Email: "test01@spark.com"})
|
||||||
app, ok := errs.AsAppError(err)
|
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)
|
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"})
|
j, err := NewJWT(&Options{Secret: "test-secret-test-secret", Issuer: "spark-im"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@ -51,10 +54,6 @@ func TestLoginServiceSendRefreshLogout(t *testing.T) {
|
|||||||
svc := NewLoginService(j, users)
|
svc := NewLoginService(j, users)
|
||||||
svc.Sessions = kicker
|
svc.Sessions = kicker
|
||||||
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{RealIP: "203.0.113.10"})
|
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"})
|
tok, err := svc.Login(ctx, &LoginInput{Email: "test01@spark.com", VerifyCode: "any", DeviceID: 7, DeviceType: "ios"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@ -74,9 +73,6 @@ func TestLoginServiceSendRefreshLogout(t *testing.T) {
|
|||||||
if kicker.n != 1 {
|
if kicker.n != 1 {
|
||||||
t.Fatalf("kick %d", kicker.n)
|
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) {
|
if _, err := (*LoginService)(nil).Refresh(ctx, RefreshInput{RefreshToken: "x"}); !errors.Is(err, errs.ErrUnauthorized) {
|
||||||
t.Fatalf("nil refresh %v", err)
|
t.Fatalf("nil refresh %v", err)
|
||||||
}
|
}
|
||||||
@ -122,8 +118,13 @@ func TestLoginServiceRecordsContextDevice(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
repo := usermodel.NewMemoryRepo()
|
repo := usermodel.NewMemoryRepo()
|
||||||
ids := seqIDs{n: 1000}
|
idgen, err := id.New(1)
|
||||||
users := userlogic.NewService(repo, usermodel.NewMemoryCodes(), &ids, nil)
|
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)
|
svc := NewLoginService(j, users)
|
||||||
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{
|
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{
|
||||||
RealIP: "198.51.100.9", Language: "en", AppVersion: "2.1.0",
|
RealIP: "198.51.100.9", Language: "en", AppVersion: "2.1.0",
|
||||||
@ -142,10 +143,3 @@ func TestLoginServiceRecordsContextDevice(t *testing.T) {
|
|||||||
t.Fatalf("%+v", dev)
|
t.Fatalf("%+v", dev)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type seqIDs struct{ n int64 }
|
|
||||||
|
|
||||||
func (s *seqIDs) NextID() (int64, error) {
|
|
||||||
s.n++
|
|
||||||
return s.n, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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
|
return &userentity.User{ID: s.UserID, SparkID: sparkID, Status: userentity.StatusActive}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s StaticUser) SendEmailCode(context.Context, string) error { return nil }
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import (
|
|||||||
msgmodel "spark-server/internal/module/message/model"
|
msgmodel "spark-server/internal/module/message/model"
|
||||||
userentity "spark-server/internal/module/user/entity"
|
userentity "spark-server/internal/module/user/entity"
|
||||||
userlogic "spark-server/internal/module/user/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/errs"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -37,7 +37,7 @@ func testFriends(t *testing.T) (*Service, *userlogic.Service, *captureGreeter) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil)
|
users := logictest.Users(ids, nil)
|
||||||
g := &captureGreeter{}
|
g := &captureGreeter{}
|
||||||
return NewService(friendmodel.NewMemoryRepo(), users, ids, g), users, g
|
return NewService(friendmodel.NewMemoryRepo(), users, ids, g), users, g
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import (
|
|||||||
msgmodel "spark-server/internal/module/message/model"
|
msgmodel "spark-server/internal/module/message/model"
|
||||||
userentity "spark-server/internal/module/user/entity"
|
userentity "spark-server/internal/module/user/entity"
|
||||||
userlogic "spark-server/internal/module/user/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/errs"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -46,7 +46,7 @@ func testGroupsStore(t *testing.T, objects testObjects) (*Service, *friendlogic.
|
|||||||
userObjs = objects
|
userObjs = objects
|
||||||
groupObjs = objects
|
groupObjs = objects
|
||||||
}
|
}
|
||||||
users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, userObjs)
|
users := logictest.Users(ids, userObjs)
|
||||||
friends := friendlogic.NewService(
|
friends := friendlogic.NewService(
|
||||||
friendmodel.NewMemoryRepo(), users, ids,
|
friendmodel.NewMemoryRepo(), users, ids,
|
||||||
friendlogic.NewGreeter(convlogic.NewP2PService(convmodel.NewMemoryP2P(), ids), msgmodel.NewMemoryRepo(), ids, delivermodel.NewMemoryQueue()),
|
friendlogic.NewGreeter(convlogic.NewP2PService(convmodel.NewMemoryP2P(), ids), msgmodel.NewMemoryRepo(), ids, delivermodel.NewMemoryQueue()),
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import (
|
|||||||
msgmodel "spark-server/internal/module/message/model"
|
msgmodel "spark-server/internal/module/message/model"
|
||||||
userentity "spark-server/internal/module/user/entity"
|
userentity "spark-server/internal/module/user/entity"
|
||||||
userlogic "spark-server/internal/module/user/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/errs"
|
||||||
"spark-server/internal/pkg/reqctx"
|
"spark-server/internal/pkg/reqctx"
|
||||||
)
|
)
|
||||||
@ -29,7 +29,7 @@ func testMessageSvc(t *testing.T) (*Service, *userlogic.Service, *friendlogic.Se
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil)
|
users := logictest.Users(ids, nil)
|
||||||
chats := convmodel.NewMemoryP2P()
|
chats := convmodel.NewMemoryP2P()
|
||||||
p2p := convlogic.NewP2PService(chats, ids)
|
p2p := convlogic.NewP2PService(chats, ids)
|
||||||
q := delivermodel.NewMemoryQueue()
|
q := delivermodel.NewMemoryQueue()
|
||||||
|
|||||||
@ -34,4 +34,6 @@ type Catalog struct {
|
|||||||
MomentStatus []Item `json:"moment_status"`
|
MomentStatus []Item `json:"moment_status"`
|
||||||
MomentNoticeKind []Item `json:"moment_notice_kind"`
|
MomentNoticeKind []Item `json:"moment_notice_kind"`
|
||||||
MomentVisibleDays []Item `json:"moment_visible_days"`
|
MomentVisibleDays []Item `json:"moment_visible_days"`
|
||||||
|
VerificationPurpose []Item `json:"verification_purpose"`
|
||||||
|
PhoneCarrier []Item `json:"phone_carrier"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,8 @@ func Catalog(lang i18n.Lang) *entity.Catalog {
|
|||||||
MomentStatus: momentStatus(lang),
|
MomentStatus: momentStatus(lang),
|
||||||
MomentNoticeKind: momentNoticeKind(lang),
|
MomentNoticeKind: momentNoticeKind(lang),
|
||||||
MomentVisibleDays: momentVisibleDays(lang),
|
MomentVisibleDays: momentVisibleDays(lang),
|
||||||
|
VerificationPurpose: verificationPurpose(lang),
|
||||||
|
PhoneCarrier: phoneCarrier(lang),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
22
internal/module/meta/logic/enums_sms.go
Normal file
22
internal/module/meta/logic/enums_sms.go
Normal file
@ -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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -39,6 +39,7 @@ func envelopeCode(lang i18n.Lang) []entity.Item {
|
|||||||
code(lang, "invalid_proxy_header", errs.ErrProxyHeader),
|
code(lang, "invalid_proxy_header", errs.ErrProxyHeader),
|
||||||
code(lang, "unauthorized", errs.ErrUnauthorized),
|
code(lang, "unauthorized", errs.ErrUnauthorized),
|
||||||
code(lang, "not_authenticated", errs.ErrNotAuthenticated),
|
code(lang, "not_authenticated", errs.ErrNotAuthenticated),
|
||||||
|
code(lang, "verify_code_invalid", errs.ErrVerifyCodeInvalid),
|
||||||
code(lang, "forbidden", errs.ErrForbidden),
|
code(lang, "forbidden", errs.ErrForbidden),
|
||||||
code(lang, "not_found", errs.ErrNotFound),
|
code(lang, "not_found", errs.ErrNotFound),
|
||||||
code(lang, "media_expired", errs.ErrMediaExpired),
|
code(lang, "media_expired", errs.ErrMediaExpired),
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import (
|
|||||||
momentmodel "spark-server/internal/module/moment/model"
|
momentmodel "spark-server/internal/module/moment/model"
|
||||||
userentity "spark-server/internal/module/user/entity"
|
userentity "spark-server/internal/module/user/entity"
|
||||||
userlogic "spark-server/internal/module/user/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/errs"
|
||||||
"spark-server/internal/pkg/reqctx"
|
"spark-server/internal/pkg/reqctx"
|
||||||
)
|
)
|
||||||
@ -34,7 +34,7 @@ func testMoment(t *testing.T) (*Service, *friendlogic.Service, *userlogic.Servic
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
friends := friendlogic.NewService(friendmodel.NewMemoryRepo(), users, ids, nil)
|
||||||
svc := NewService(momentmodel.NewMemoryRepo(), friends, users, &memObj{}, ids)
|
svc := NewService(momentmodel.NewMemoryRepo(), friends, users, &memObj{}, ids)
|
||||||
return svc, friends, users
|
return svc, friends, users
|
||||||
|
|||||||
15
internal/module/sms/entity/carrier.go
Normal file
15
internal/module/sms/entity/carrier.go
Normal file
@ -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
|
||||||
|
}
|
||||||
18
internal/module/sms/entity/channel.go
Normal file
18
internal/module/sms/entity/channel.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
54
internal/module/sms/entity/phone.go
Normal file
54
internal/module/sms/entity/phone.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
18
internal/module/sms/entity/purpose.go
Normal file
18
internal/module/sms/entity/purpose.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
31
internal/module/sms/entity/target.go
Normal file
31
internal/module/sms/entity/target.go
Normal file
@ -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
|
||||||
|
}
|
||||||
17
internal/module/sms/entity/target_test.go
Normal file
17
internal/module/sms/entity/target_test.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
18
internal/module/sms/logic/provider.go
Normal file
18
internal/module/sms/logic/provider.go
Normal file
@ -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
|
||||||
|
}
|
||||||
178
internal/module/sms/logic/send.go
Normal file
178
internal/module/sms/logic/send.go
Normal file
@ -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
|
||||||
|
}
|
||||||
110
internal/module/sms/logic/send_more_test.go
Normal file
110
internal/module/sms/logic/send_more_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
24
internal/module/sms/logic/send_test.go
Normal file
24
internal/module/sms/logic/send_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
69
internal/module/sms/logic/service.go
Normal file
69
internal/module/sms/logic/service.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
11
internal/module/sms/logic/store.go
Normal file
11
internal/module/sms/logic/store.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
65
internal/module/sms/logic/verify.go
Normal file
65
internal/module/sms/logic/verify.go
Normal file
@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
34
internal/module/sms/logic/verify_test.go
Normal file
34
internal/module/sms/logic/verify_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
internal/module/sms/model/memory_store.go
Normal file
58
internal/module/sms/model/memory_store.go
Normal file
@ -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
|
||||||
|
}
|
||||||
69
internal/module/sms/model/redis_store.go
Normal file
69
internal/module/sms/model/redis_store.go
Normal file
@ -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
|
||||||
|
}
|
||||||
157
internal/module/square/entity/content.go
Normal file
157
internal/module/square/entity/content.go
Normal file
@ -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
|
||||||
|
}
|
||||||
83
internal/module/square/entity/kind.go
Normal file
83
internal/module/square/entity/kind.go
Normal file
@ -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
|
||||||
|
}
|
||||||
71
internal/module/square/entity/post.go
Normal file
71
internal/module/square/entity/post.go
Normal file
@ -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}
|
||||||
|
}
|
||||||
57
internal/module/square/logic/album.go
Normal file
57
internal/module/square/logic/album.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
155
internal/module/square/logic/attach.go
Normal file
155
internal/module/square/logic/attach.go
Normal file
@ -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
|
||||||
|
}
|
||||||
117
internal/module/square/logic/comment.go
Normal file
117
internal/module/square/logic/comment.go
Normal file
@ -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
|
||||||
|
}
|
||||||
36
internal/module/square/logic/delete.go
Normal file
36
internal/module/square/logic/delete.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
57
internal/module/square/logic/feed.go
Normal file
57
internal/module/square/logic/feed.go
Normal file
@ -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
|
||||||
|
}
|
||||||
59
internal/module/square/logic/like.go
Normal file
59
internal/module/square/logic/like.go
Normal file
@ -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
|
||||||
|
}
|
||||||
44
internal/module/square/logic/limit.go
Normal file
44
internal/module/square/logic/limit.go
Normal file
@ -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
|
||||||
|
}
|
||||||
124
internal/module/square/logic/notice.go
Normal file
124
internal/module/square/logic/notice.go
Normal file
@ -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}
|
||||||
|
}
|
||||||
146
internal/module/square/logic/publish.go
Normal file
146
internal/module/square/logic/publish.go
Normal file
@ -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
|
||||||
|
}
|
||||||
22
internal/module/square/logic/purge.go
Normal file
22
internal/module/square/logic/purge.go
Normal file
@ -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
|
||||||
|
}
|
||||||
75
internal/module/square/logic/service.go
Normal file
75
internal/module/square/logic/service.go
Normal file
@ -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
|
||||||
|
}
|
||||||
249
internal/module/square/logic/square_test.go
Normal file
249
internal/module/square/logic/square_test.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
104
internal/module/square/logic/view.go
Normal file
104
internal/module/square/logic/view.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
83
internal/module/square/logic/visible.go
Normal file
83
internal/module/square/logic/visible.go
Normal file
@ -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
|
||||||
|
}
|
||||||
224
internal/module/square/model/memory.go
Normal file
224
internal/module/square/model/memory.go
Normal file
@ -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
|
||||||
|
}
|
||||||
87
internal/module/square/model/memory_purge.go
Normal file
87
internal/module/square/model/memory_purge.go
Normal file
@ -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
|
||||||
|
}
|
||||||
156
internal/module/square/model/memory_write.go
Normal file
156
internal/module/square/model/memory_write.go
Normal file
@ -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
|
||||||
|
}
|
||||||
175
internal/module/square/model/post.go
Normal file
175
internal/module/square/model/post.go
Normal file
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
102
internal/module/square/model/purge.go
Normal file
102
internal/module/square/model/purge.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
186
internal/module/square/model/query.go
Normal file
186
internal/module/square/model/query.go
Normal file
@ -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
|
||||||
|
}
|
||||||
50
internal/module/square/model/repo.go
Normal file
50
internal/module/square/model/repo.go
Normal file
@ -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
|
||||||
|
}
|
||||||
225
internal/module/square/model/write.go
Normal file
225
internal/module/square/model/write.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
@ -18,6 +18,8 @@ const (
|
|||||||
PurposeMomentImage = "moment_image"
|
PurposeMomentImage = "moment_image"
|
||||||
PurposeMomentVideo = "moment_video"
|
PurposeMomentVideo = "moment_video"
|
||||||
PurposeMomentCover = "moment_cover"
|
PurposeMomentCover = "moment_cover"
|
||||||
|
PurposeSquareImage = "square_image"
|
||||||
|
PurposeSquareVideo = "square_video"
|
||||||
|
|
||||||
PrefixPerm = "perm/"
|
PrefixPerm = "perm/"
|
||||||
PrefixChat = "chat/"
|
PrefixChat = "chat/"
|
||||||
@ -33,6 +35,8 @@ const (
|
|||||||
PrefixMomentImage = "perm/moment/image/"
|
PrefixMomentImage = "perm/moment/image/"
|
||||||
PrefixMomentVideo = "perm/moment/video/"
|
PrefixMomentVideo = "perm/moment/video/"
|
||||||
PrefixMomentCover = "perm/moment/cover/"
|
PrefixMomentCover = "perm/moment/cover/"
|
||||||
|
PrefixSquareImage = "perm/square/image/"
|
||||||
|
PrefixSquareVideo = "perm/square/video/"
|
||||||
|
|
||||||
MethodPUT = "PUT"
|
MethodPUT = "PUT"
|
||||||
MethodGET = "GET"
|
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)
|
return fmt.Sprintf("%s%s/%s.%s", PrefixMomentVideo, day, name, ext)
|
||||||
case PurposeMomentCover:
|
case PurposeMomentCover:
|
||||||
return fmt.Sprintf("%s%s/%s.%s", PrefixMomentCover, day, name, ext)
|
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:
|
default:
|
||||||
return fmt.Sprintf("%s%s/%s/%s.%s", PrefixChat, chatKind(purpose), day, name, ext)
|
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},
|
PurposeMomentCover: {Purpose: PurposeMomentCover, MaxSize: maxAvatar, MIMEs: imageMIMEs},
|
||||||
PurposeMomentImage: {Purpose: PurposeMomentImage, MaxSize: maxMoment, MIMEs: momentImageMIMEs},
|
PurposeMomentImage: {Purpose: PurposeMomentImage, MaxSize: maxMoment, MIMEs: momentImageMIMEs},
|
||||||
PurposeMomentVideo: {Purpose: PurposeMomentVideo, MaxSize: maxVideo, MIMEs: videoMIMEs},
|
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},
|
PurposeChatImage: {Purpose: PurposeChatImage, MaxSize: maxImage, MIMEs: imageMIMEs},
|
||||||
PurposeChatGIF: {Purpose: PurposeChatGIF, MaxSize: maxGIF, MIMEs: gifMIMEs},
|
PurposeChatGIF: {Purpose: PurposeChatGIF, MaxSize: maxGIF, MIMEs: gifMIMEs},
|
||||||
PurposeChatVoice: {Purpose: PurposeChatVoice, MaxSize: maxVoice, MIMEs: voiceMIMEs},
|
PurposeChatVoice: {Purpose: PurposeChatVoice, MaxSize: maxVoice, MIMEs: voiceMIMEs},
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
// CreateInput requests a single-object Presigned PUT. The client does not choose the key.
|
// CreateInput requests a single-object Presigned PUT. The client does not choose the key.
|
||||||
type CreateInput struct {
|
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"`
|
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"`
|
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"`
|
GroupID int64 `json:"group_id" validate:"omitempty,gt=0" label:"field.group_id"`
|
||||||
|
|||||||
@ -6,10 +6,18 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
smsentity "spark-server/internal/module/sms/entity"
|
||||||
"spark-server/internal/module/user/entity"
|
"spark-server/internal/module/user/entity"
|
||||||
"spark-server/internal/pkg/errs"
|
"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.
|
// UserDataCleaner removes a canceled user's rows in another module.
|
||||||
type UserDataCleaner interface {
|
type UserDataCleaner interface {
|
||||||
PurgeUser(ctx context.Context, userID int64) error
|
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...)
|
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 {
|
if !s.ready() || userID <= 0 {
|
||||||
return errs.ErrUnauthorized
|
return errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
@ -61,6 +69,9 @@ func (s *Service) DeleteAccount(ctx context.Context, userID int64) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := s.verifyDeleteCode(ctx, email, in.VerifyCode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
s.revokeAccess(ctx, userID)
|
s.revokeAccess(ctx, userID)
|
||||||
if err := s.purgeRelated(ctx, userID); err != nil {
|
if err := s.purgeRelated(ctx, userID); err != nil {
|
||||||
return err
|
return err
|
||||||
@ -69,12 +80,26 @@ func (s *Service) DeleteAccount(ctx context.Context, userID int64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.deleteAvatarObject(ctx, u.Avatar)
|
s.deleteAvatarObject(ctx, u.Avatar)
|
||||||
if email != "" && s.codes != nil {
|
if email != "" && s.otp != nil {
|
||||||
_ = s.codes.Del(ctx, email)
|
s.otp.ClearTarget(ctx, smsentity.ChannelEmail, email)
|
||||||
}
|
}
|
||||||
return nil
|
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) {
|
func (s *Service) lookupEmail(ctx context.Context, userID int64) (string, error) {
|
||||||
email, err := s.repo.GetEmail(ctx, userID)
|
email, err := s.repo.GetEmail(ctx, userID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"spark-server/internal/infrastructure/id"
|
"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"
|
friendlogic "spark-server/internal/module/friend/logic"
|
||||||
friendmodel "spark-server/internal/module/friend/model"
|
friendmodel "spark-server/internal/module/friend/model"
|
||||||
momentlogic "spark-server/internal/module/moment/logic"
|
momentlogic "spark-server/internal/module/moment/logic"
|
||||||
@ -24,7 +26,7 @@ func TestDeleteAccountArchivesAndAllowsReregister(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := repo.GetByID(ctx, u.ID); !errors.Is(err, errs.ErrNotFound) {
|
if _, err := repo.GetByID(ctx, u.ID); !errors.Is(err, errs.ErrNotFound) {
|
||||||
@ -52,7 +54,9 @@ func TestDeleteAccountPurgesFriendsAndMoments(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
repo := model.NewMemoryRepo()
|
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)
|
friends := friendlogic.NewService(friendmodel.NewMemoryRepo(), users, ids, nil)
|
||||||
moments := momentlogic.NewService(momentmodel.NewMemoryRepo(), friends, users, nil, ids)
|
moments := momentlogic.NewService(momentmodel.NewMemoryRepo(), friends, users, nil, ids)
|
||||||
users.BindAccountCleanup(nil, nil, friends, moments)
|
users.BindAccountCleanup(nil, nil, friends, moments)
|
||||||
@ -77,7 +81,7 @@ func TestDeleteAccountPurgesFriendsAndMoments(t *testing.T) {
|
|||||||
if err != nil || pub == nil {
|
if err != nil || pub == nil {
|
||||||
t.Fatalf("publish %+v %v", pub, err)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
ok, err := friends.AreFriends(ctx, a.ID, b.ID)
|
ok, err := friends.AreFriends(ctx, a.ID, b.ID)
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,11 +2,11 @@ package logic
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/subtle"
|
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
smsentity "spark-server/internal/module/sms/entity"
|
||||||
"spark-server/internal/module/user/entity"
|
"spark-server/internal/module/user/entity"
|
||||||
"spark-server/internal/pkg/errs"
|
"spark-server/internal/pkg/errs"
|
||||||
"spark-server/internal/pkg/i18n"
|
"spark-server/internal/pkg/i18n"
|
||||||
@ -56,15 +56,10 @@ func (s *Service) checkVerifyCode(ctx context.Context, email, verifyCode string)
|
|||||||
if entity.IsTestEmail(email) {
|
if entity.IsTestEmail(email) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
want, err := s.codes.Get(ctx, email)
|
if s.otp == nil {
|
||||||
if err != nil {
|
return errs.ErrAvailable
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
if want == "" || subtle.ConstantTimeCompare([]byte(want), []byte(verifyCode)) != 1 {
|
return s.otp.CheckAndConsume(ctx, smsentity.ChannelEmail, smsentity.PurposeLogin, email, verifyCode)
|
||||||
return errs.ErrUnauthorized
|
|
||||||
}
|
|
||||||
_ = s.codes.Del(ctx, email)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) getOrCreate(ctx context.Context, email string) (*entity.User, error) {
|
func (s *Service) getOrCreate(ctx context.Context, email string) (*entity.User, error) {
|
||||||
|
|||||||
@ -4,12 +4,16 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"image/png"
|
"image/png"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"spark-server/internal/infrastructure/id"
|
"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/entity"
|
||||||
"spark-server/internal/module/user/model"
|
"spark-server/internal/module/user/model"
|
||||||
"spark-server/internal/pkg/errs"
|
"spark-server/internal/pkg/errs"
|
||||||
@ -111,10 +115,10 @@ func TestAuthenticateNonTestNeedsCode(t *testing.T) {
|
|||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
device := &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceWeb}
|
device := &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceWeb}
|
||||||
_, err := svc.AuthenticateEmail(ctx, "ada@example.com", "123456", device)
|
_, 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)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
u, err := svc.AuthenticateEmail(ctx, "ada@example.com", "123456", device)
|
u, err := svc.AuthenticateEmail(ctx, "ada@example.com", "123456", device)
|
||||||
@ -125,7 +129,7 @@ func TestAuthenticateNonTestNeedsCode(t *testing.T) {
|
|||||||
t.Fatal(u)
|
t.Fatal(u)
|
||||||
}
|
}
|
||||||
_, err = svc.AuthenticateEmail(ctx, "ada@example.com", "123456", device)
|
_, 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)
|
t.Fatalf("consumed: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -157,7 +161,9 @@ func TestRegisterFallsBackToDataURIWithoutObjectStore(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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}
|
device := &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS}
|
||||||
u, err := svc.AuthenticateEmail(t.Context(), "test09@spark.com", "1", device)
|
u, err := svc.AuthenticateEmail(t.Context(), "test09@spark.com", "1", device)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -173,7 +179,9 @@ func TestRegisterFallsBackWhenObjectPutFails(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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}
|
device := &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS}
|
||||||
u, err := svc.AuthenticateEmail(t.Context(), "test09@spark.com", "1", device)
|
u, err := svc.AuthenticateEmail(t.Context(), "test09@spark.com", "1", device)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -37,7 +37,7 @@ func TestUpdateSparkID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_, err = svc.UpdateSparkID(ctx, u.ID, "1bad")
|
_, err = svc.UpdateSparkID(ctx, u.ID, "1bad")
|
||||||
app, ok := errs.AsAppError(err)
|
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)
|
t.Fatalf("invalid %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
smsentity "spark-server/internal/module/sms/entity"
|
||||||
"spark-server/internal/module/user/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
|
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).
|
// Service is the user application use-case (HTTP and TCP share it).
|
||||||
type Service struct {
|
type Service struct {
|
||||||
repo Repository
|
repo Repository
|
||||||
codes CodeStore
|
otp OTP
|
||||||
ids IDGen
|
ids IDGen
|
||||||
objects Objects
|
objects Objects
|
||||||
tokens TokenRevoker
|
tokens TokenRevoker
|
||||||
@ -48,8 +55,8 @@ type Service struct {
|
|||||||
cleaners []UserDataCleaner
|
cleaners []UserDataCleaner
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(repo Repository, codes CodeStore, ids IDGen, objects Objects) *Service {
|
func NewService(repo Repository, otp OTP, ids IDGen, objects Objects) *Service {
|
||||||
return &Service{repo: repo, codes: codes, ids: ids, objects: objects}
|
return &Service{repo: repo, otp: otp, ids: ids, objects: objects}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ready() bool {
|
func (s *Service) ready() bool {
|
||||||
|
|||||||
@ -5,18 +5,21 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"spark-server/internal/infrastructure/id"
|
"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"
|
"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()
|
t.Helper()
|
||||||
ids, err := id.New(1)
|
ids, err := id.New(1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
repo := model.NewMemoryRepo()
|
repo := model.NewMemoryRepo()
|
||||||
codes := model.NewMemoryCodes()
|
store := smsmodel.NewMemoryStore()
|
||||||
return NewService(repo, codes, ids, newMemObjects()), repo, codes
|
smsSvc := smslogic.NewService(store, nil, nil, nil)
|
||||||
|
return NewService(repo, smsSvc, ids, newMemObjects()), repo, store
|
||||||
}
|
}
|
||||||
|
|
||||||
type memObjects struct {
|
type memObjects struct {
|
||||||
|
|||||||
24
internal/module/user/logictest/wire.go
Normal file
24
internal/module/user/logictest/wire.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -9,6 +9,7 @@ var (
|
|||||||
ErrProxyHeader = NewError("40004", "invalid proxy protocol header", "")
|
ErrProxyHeader = NewError("40004", "invalid proxy protocol header", "")
|
||||||
ErrUnauthorized = NewError("40101", "unauthorized", "")
|
ErrUnauthorized = NewError("40101", "unauthorized", "")
|
||||||
ErrNotAuthenticated = NewError("40102", "not authenticated", "")
|
ErrNotAuthenticated = NewError("40102", "not authenticated", "")
|
||||||
|
ErrVerifyCodeInvalid = NewError("40103", "verification code invalid", "")
|
||||||
ErrForbidden = NewError("40301", "forbidden", "")
|
ErrForbidden = NewError("40301", "forbidden", "")
|
||||||
ErrNotFound = NewError("40401", "not found", "")
|
ErrNotFound = NewError("40401", "not found", "")
|
||||||
ErrMediaExpired = NewError("40402", "media expired", "")
|
ErrMediaExpired = NewError("40402", "media expired", "")
|
||||||
|
|||||||
@ -23,6 +23,9 @@ func init() {
|
|||||||
"err.40102": {
|
"err.40102": {
|
||||||
ZhCN: "未登录", En: "not authenticated", ZhTW: "未登入",
|
ZhCN: "未登录", En: "not authenticated", ZhTW: "未登入",
|
||||||
},
|
},
|
||||||
|
"err.40103": {
|
||||||
|
ZhCN: "验证码无效", En: "Invalid verification code", ZhTW: "驗證碼無效",
|
||||||
|
},
|
||||||
"err.40301": {
|
"err.40301": {
|
||||||
ZhCN: "无权限", En: "forbidden", ZhTW: "無權限",
|
ZhCN: "无权限", En: "forbidden", ZhTW: "無權限",
|
||||||
},
|
},
|
||||||
|
|||||||
61
internal/pkg/i18n/catalog_validate_sms.go
Normal file
61
internal/pkg/i18n/catalog_validate_sms.go
Normal file
@ -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: "中國電信",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
65
internal/pkg/i18n/catalog_validate_square.go
Normal file
65
internal/pkg/i18n/catalog_validate_square.go
Normal file
@ -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: "广场影片"},
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@ import "testing"
|
|||||||
|
|
||||||
func TestParseLanguage(t *testing.T) {
|
func TestParseLanguage(t *testing.T) {
|
||||||
cases := map[string]Lang{
|
cases := map[string]Lang{
|
||||||
"": ZhCN,
|
"": En,
|
||||||
"zh": ZhCN,
|
"zh": ZhCN,
|
||||||
"zh-CN": ZhCN,
|
"zh-CN": ZhCN,
|
||||||
"zh_CN": ZhCN,
|
"zh_CN": ZhCN,
|
||||||
|
|||||||
@ -148,6 +148,35 @@ const (
|
|||||||
KeyMomentVisibleDaysInvalid = "validate.moment.visible_days_invalid"
|
KeyMomentVisibleDaysInvalid = "validate.moment.visible_days_invalid"
|
||||||
KeyMomentHideNotFriend = "validate.moment.hide_not_friend"
|
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"
|
NotifyKickReplaced = "notify.kick.replaced"
|
||||||
NotifyKickLogout = "notify.kick.logout"
|
NotifyKickLogout = "notify.kick.logout"
|
||||||
NotifyKickDeleted = "notify.kick.deleted"
|
NotifyKickDeleted = "notify.kick.deleted"
|
||||||
|
|||||||
@ -11,7 +11,7 @@ const (
|
|||||||
ZhCN Lang = "zh-CN"
|
ZhCN Lang = "zh-CN"
|
||||||
En Lang = "en"
|
En Lang = "en"
|
||||||
ZhTW Lang = "zh-TW"
|
ZhTW Lang = "zh-TW"
|
||||||
Default = ZhCN
|
Default = En
|
||||||
)
|
)
|
||||||
|
|
||||||
func (l Lang) String() string {
|
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.
|
// 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 {
|
func Parse(raw string) Lang {
|
||||||
for _, part := range strings.Split(raw, ",") {
|
for _, part := range strings.Split(raw, ",") {
|
||||||
tag, _, _ := strings.Cut(strings.TrimSpace(part), ";")
|
tag, _, _ := strings.Cut(strings.TrimSpace(part), ";")
|
||||||
@ -32,6 +33,21 @@ func Parse(raw string) Lang {
|
|||||||
return Default
|
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) {
|
func match(tag string) (Lang, bool) {
|
||||||
t := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(tag), "_", "-"))
|
t := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(tag), "_", "-"))
|
||||||
if t == "" {
|
if t == "" {
|
||||||
|
|||||||
@ -30,7 +30,7 @@ func TestOKAndFailEnvelope(t *testing.T) {
|
|||||||
if fail.Code != errs.ErrNotAuthenticated.Code || fail.Msg != "login first" || string(fail.Data) != "{}" {
|
if fail.Code != errs.ErrNotAuthenticated.Code || fail.Msg != "login first" || string(fail.Data) != "{}" {
|
||||||
t.Fatalf("fail %+v", fail)
|
t.Fatalf("fail %+v", fail)
|
||||||
}
|
}
|
||||||
zh := FromError(errs.ErrUnauthorized)
|
zh := FromErrorLang(i18n.ZhCN, errs.ErrUnauthorized)
|
||||||
if zh.Msg != "未授权" {
|
if zh.Msg != "未授权" {
|
||||||
t.Fatalf("zh %q", zh.Msg)
|
t.Fatalf("zh %q", zh.Msg)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -87,7 +87,7 @@ func TestLangFromContext(t *testing.T) {
|
|||||||
if Language(ctx) != "en" {
|
if Language(ctx) != "en" {
|
||||||
t.Fatalf("got %q", Language(ctx))
|
t.Fatalf("got %q", Language(ctx))
|
||||||
}
|
}
|
||||||
if Language(t.Context()) != "zh-CN" {
|
if Language(t.Context()) != "en" {
|
||||||
t.Fatalf("default %q", Language(t.Context()))
|
t.Fatalf("default %q", Language(t.Context()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,7 +34,7 @@ func engine() *validator.Validate {
|
|||||||
return v
|
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 {
|
func Struct(val any) error {
|
||||||
return StructLang(i18n.Default, val)
|
return StructLang(i18n.Default, val)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,12 +23,12 @@ func TestStructCustomAndDefaultMessages(t *testing.T) {
|
|||||||
if app.Code != errs.ErrInvalidArgument.Code {
|
if app.Code != errs.ErrInvalidArgument.Code {
|
||||||
t.Fatalf("code %s", app.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)
|
t.Fatalf("msg %s", app.Msg)
|
||||||
}
|
}
|
||||||
err = Struct(sample{Phone: "1", Password: "123", DeviceID: 1})
|
err = Struct(sample{Phone: "1", Password: "123", DeviceID: 1})
|
||||||
app, ok = errs.AsAppError(err)
|
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)
|
t.Fatalf("min %v", err)
|
||||||
}
|
}
|
||||||
if err := Struct(sample{Phone: "1", Password: "123456", DeviceID: 1}); err != nil {
|
if err := Struct(sample{Phone: "1", Password: "123456", DeviceID: 1}); err != nil {
|
||||||
|
|||||||
@ -76,7 +76,7 @@ func AuthRateLimit(l *ratelimit.Limiter) gin.HandlerFunc {
|
|||||||
|
|
||||||
func authLimitPath(path string) bool {
|
func authLimitPath(path string) bool {
|
||||||
switch path {
|
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
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@ -87,7 +87,7 @@ func publicPath(path string) bool {
|
|||||||
switch path {
|
switch path {
|
||||||
case "/healthz", "/readyz", "/openapi.yaml", "/docs", "/docs/":
|
case "/healthz", "/readyz", "/openapi.yaml", "/docs", "/docs/":
|
||||||
return true
|
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
|
return true
|
||||||
default:
|
default:
|
||||||
// Admin routes use AdminAuth (Java popadmin JWT), not client Bearer.
|
// Admin routes use AdminAuth (Java popadmin JWT), not client Bearer.
|
||||||
|
|||||||
@ -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` 由客户端生成并持久化,不要每次换。
|
2. `POST /v1/auth/login` Body 拿 `access_token` / `refresh_token`。`device_id` 由客户端生成并持久化,不要每次换。
|
||||||
3. 其它 HTTP:Header `Authorization: Bearer …`,可选 `X-Device-ID`、`X-Language`。
|
3. 其它 HTTP:Header `Authorization: Bearer …`,可选 `X-Device-ID`、`X-Language`。
|
||||||
4. 续期:`POST /v1/auth/token/refresh`,**Body** `{"refresh_token"}`,不要带 Bearer。
|
4. 续期:`POST /v1/auth/token/refresh`,**Body** `{"refresh_token"}`,不要带 Bearer。
|
||||||
@ -362,6 +362,7 @@ tags:
|
|||||||
- name: files
|
- name: files
|
||||||
- name: favorites
|
- name: favorites
|
||||||
- name: moments
|
- name: moments
|
||||||
|
- name: square
|
||||||
paths:
|
paths:
|
||||||
/healthz:
|
/healthz:
|
||||||
get:
|
get:
|
||||||
@ -503,24 +504,28 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/EnumsEnvelope"
|
$ref: "#/components/schemas/EnumsEnvelope"
|
||||||
/v1/auth/email-codes:
|
/v1/sms/codes:
|
||||||
post:
|
post:
|
||||||
tags: [auth]
|
tags: [sms]
|
||||||
security: []
|
security: []
|
||||||
summary: 发送邮箱登录验证码
|
summary: 发送验证码
|
||||||
description: |
|
description: |
|
||||||
验证码 5 分钟有效,同一邮箱 60 秒内只能发一次。
|
Body:`purpose` 1=登录 2=注销;`target` 为邮箱或大陆手机号(服务端识别,无需 channel)。
|
||||||
测试账号 `test01@spark.com`~`test10@spark.com` 无需发码,登录时任意填写 `verify_code`。
|
**Header `X-Language` 必填**(不接受仅 Accept-Language),用于邮件正文语言;未识别时默认 `en`。
|
||||||
当前不发送真实邮件,验证码写入 Redis(无 Redis 时为进程内存)。
|
登录发码无需 Bearer;注销发码需 Bearer,且 `target` 须为当前绑定邮箱。
|
||||||
operationId: sendEmailCode
|
邮箱经 SMTP(`noreply@sparkmail.wyrs365.com`);手机走阿里云短信。
|
||||||
|
operationId: sendVerificationCode
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/X-Language"
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/SendEmailCodeRequest"
|
$ref: "#/components/schemas/SendVerificationCodeRequest"
|
||||||
example:
|
example:
|
||||||
email: ada@example.com
|
purpose: 1
|
||||||
|
target: ada@example.com
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: 统一 envelope
|
description: 统一 envelope
|
||||||
@ -528,13 +533,6 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Envelope"
|
$ref: "#/components/schemas/Envelope"
|
||||||
examples:
|
|
||||||
ok:
|
|
||||||
value:
|
|
||||||
code: "0"
|
|
||||||
msg: ok
|
|
||||||
data:
|
|
||||||
expires_in: 300
|
|
||||||
/v1/auth/login:
|
/v1/auth/login:
|
||||||
post:
|
post:
|
||||||
tags: [auth]
|
tags: [auth]
|
||||||
@ -693,9 +691,16 @@ paths:
|
|||||||
summary: 注销当前账号
|
summary: 注销当前账号
|
||||||
description: |
|
description: |
|
||||||
吊销全部设备的 JWT 并踢掉 TCP。将 `users` 行迁入 `user_delete` 后删除原行(邮箱可重新注册)。
|
吊销全部设备的 JWT 并踢掉 TCP。将 `users` 行迁入 `user_delete` 后删除原行(邮箱可重新注册)。
|
||||||
|
非测试账号需先 `POST /v1/sms/codes`(purpose=2, channel=1)向绑定邮箱发码,再在本接口提交 `verify_code`。
|
||||||
同时清除好友关系、朋友圈、收藏、群成员(群主把群转给一名管理员,没有管理员则转给成员表第一位后退出;仅剩自己则解散)、会话成员、收件箱 Redis 键与 mailbox。
|
同时清除好友关系、朋友圈、收藏、群成员(群主把群转给一名管理员,没有管理员则转给成员表第一位后退出;仅剩自己则解散)、会话成员、收件箱 Redis 键与 mailbox。
|
||||||
对方仍可见历史消息(不删 `messages`)。
|
对方仍可见历史消息(不删 `messages`)。
|
||||||
operationId: deleteMe
|
operationId: deleteMe
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/DeleteAccountRequest"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: 统一 envelope
|
description: 统一 envelope
|
||||||
@ -1121,6 +1126,7 @@ paths:
|
|||||||
`group_avatar` 写入 `perm/group/{群号}/{name}.{ext}`(需 `group_id`,群主/管理员,匿名 GET);
|
`group_avatar` 写入 `perm/group/{群号}/{name}.{ext}`(需 `group_id`,群主/管理员,匿名 GET);
|
||||||
聊天按类型:`chat/image|voice|video|gif|file/{yyyyMMdd}/{name}.{ext}`(桶生命周期按前缀过期)。
|
聊天按类型:`chat/image|voice|video|gif|file/{yyyyMMdd}/{name}.{ext}`(桶生命周期按前缀过期)。
|
||||||
朋友圈:`moment_image` / `moment_video` / `moment_cover` 写入 `perm/moment/{image|video|cover}/{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。
|
`{name}` 为服务端 16 位 base62 随机串,不占用 Snowflake。
|
||||||
发图片:客户端压成 JPEG/PNG 后 `POST /v1/files/batch` 直传缩略图、压缩大图、原图(未勾选原图只传前两项)。
|
发图片:客户端压成 JPEG/PNG 后 `POST /v1/files/batch` 直传缩略图、压缩大图、原图(未勾选原图只传前两项)。
|
||||||
服务端不转码。iOS HEIC、Android WebP 须客户端先转 JPEG/PNG,两端才能互看。
|
服务端不转码。iOS HEIC、Android WebP 须客户端先转 JPEG/PNG,两端才能互看。
|
||||||
@ -1602,6 +1608,209 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Envelope"
|
$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:
|
/v1/conversations:
|
||||||
get:
|
get:
|
||||||
tags: [conversations]
|
tags: [conversations]
|
||||||
@ -1830,7 +2039,7 @@ components:
|
|||||||
description: 缺省 zh-CN
|
description: 缺省 zh-CN
|
||||||
UploadPurpose:
|
UploadPurpose:
|
||||||
type: string
|
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:
|
SystemKind:
|
||||||
type: string
|
type: string
|
||||||
enum: [friend.became, group.created, group.invite, group.leave, group.kick, group.dissolve, group.transfer]
|
enum: [friend.became, group.created, group.invite, group.leave, group.kick, group.dissolve, group.transfer]
|
||||||
@ -2076,13 +2285,25 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/EnumItem"
|
$ref: "#/components/schemas/EnumItem"
|
||||||
SendEmailCodeRequest:
|
SendVerificationCodeRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [email]
|
required: [purpose, target]
|
||||||
properties:
|
properties:
|
||||||
email:
|
purpose:
|
||||||
|
$ref: "#/components/schemas/VerificationPurpose"
|
||||||
|
target:
|
||||||
type: string
|
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:
|
LoginRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [email, verify_code, device_id]
|
required: [email, verify_code, device_id]
|
||||||
@ -2541,6 +2762,14 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
description: 朋友圈 Snowflake post_id。
|
description: 朋友圈 Snowflake post_id。
|
||||||
|
SquarePostID:
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: 广场 Snowflake post_id。
|
||||||
GroupID:
|
GroupID:
|
||||||
in: path
|
in: path
|
||||||
name: id
|
name: id
|
||||||
|
|||||||
@ -20,8 +20,7 @@ import (
|
|||||||
msgentity "spark-server/internal/module/message/entity"
|
msgentity "spark-server/internal/module/message/entity"
|
||||||
msgmodel "spark-server/internal/module/message/model"
|
msgmodel "spark-server/internal/module/message/model"
|
||||||
uploadlogic "spark-server/internal/module/upload/logic"
|
uploadlogic "spark-server/internal/module/upload/logic"
|
||||||
userlogic "spark-server/internal/module/user/logic"
|
"spark-server/internal/module/user/logictest"
|
||||||
usermodel "spark-server/internal/module/user/model"
|
|
||||||
"spark-server/internal/pkg/errs"
|
"spark-server/internal/pkg/errs"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -45,7 +44,7 @@ func testFileEngine(t *testing.T) (*ginEngineBundle, *id.Generator, *msgmodel.Me
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil)
|
users, _ := logictest.UsersAndSMS(ids, nil)
|
||||||
msgs := msgmodel.NewMemoryRepo()
|
msgs := msgmodel.NewMemoryRepo()
|
||||||
convs := convmodel.NewMemoryP2P()
|
convs := convmodel.NewMemoryP2P()
|
||||||
files := uploadlogic.NewService(&stubPresign{}, uploadlogic.Options{
|
files := uploadlogic.NewService(&stubPresign{}, uploadlogic.Options{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user