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).
|
||||
- SQL tables and GORM models carry COMMENT on the table and every column.
|
||||
- After adding or changing `migrations/*.sql`, run `go run ./cmd/migrate -config configs/config.yaml` in this session. Do not leave it for deploy.
|
||||
- Run `make lint` (funlen 100).
|
||||
|
||||
## Before declaring done (required)
|
||||
|
||||
After modifying this repo, run from the project root:
|
||||
|
||||
```bash
|
||||
make lint
|
||||
make test
|
||||
```
|
||||
|
||||
Both must exit 0. `make test` runs all packages and enforces logic-layer coverage (see root `Makefile`). Do not mark work complete or hand off to the user until lint and test pass. Fix failures and re-run.
|
||||
|
||||
## Additional resources
|
||||
|
||||
|
||||
@ -79,7 +79,8 @@ Public HTTP is `/v1/{resource}/...`. Register with Gin groups in `internal/trans
|
||||
| Group | Prefix | Examples |
|
||||
|-------|--------|----------|
|
||||
| system | `/healthz`, `/readyz`, `/docs` | liveness, readiness, OpenAPI(`/docs` API,`/docs/enums` 枚举,`/docs/errors` 错误码) |
|
||||
| auth | `/v1/auth` | `POST /v1/auth/login`, `POST /v1/auth/email-codes`, `POST /v1/auth/token/refresh`, `POST /v1/auth/logout` |
|
||||
| auth | `/v1/auth` | `POST /v1/auth/login`, `POST /v1/auth/token/refresh`, `POST /v1/auth/logout` |
|
||||
| sms | `/v1/sms` | `POST /v1/sms/codes`(`X-Language` 必填) |
|
||||
| users | `/v1/users` | `GET/PATCH/DELETE /v1/users/me` |
|
||||
| friends | `/v1/friends` | apply, requests, accept, profile, remark, pin, block, report;`GET /v1/friends/lookup?q=` |
|
||||
| groups | `/v1/groups` | create, profile, members, invite, kick, leave, transfer, dissolve |
|
||||
@ -94,7 +95,7 @@ Rules:
|
||||
|
||||
- Noun resources, HTTP verbs for actions. No `/getUser`, `/sendMessage` under HTTP.
|
||||
- Login stays `POST /v1/auth/login` (create session). Do not put login under `/v1/users`.
|
||||
- Anonymous paths belong in `publicPath` (login, email-codes, token refresh, healthz, readyz, docs, `GET /v1/meta/enums`).
|
||||
- Anonymous paths belong in `publicPath` (login, `POST /v1/sms/codes`, token refresh, healthz, readyz, docs, `GET /v1/meta/enums`).
|
||||
- OpenAPI `tags` must match these groups. Docs jump tags `enums` / `errors` sit above `system` and open `/docs/enums`、`/docs/errors`.
|
||||
|
||||
## Checklist when adding an HTTP API
|
||||
|
||||
@ -4,14 +4,16 @@ User-visible **error `msg`** and **notification copy** are never hardcoded in ha
|
||||
|
||||
## Language on every request
|
||||
|
||||
Canonical tags: `zh-CN` (default), `en`, `zh-TW`. Parse with `i18n.Parse`; store on `reqctx.RequestContext.Language`. Read with `reqctx.Lang(ctx)` / `reqctx.Language(ctx)`.
|
||||
Canonical tags: `zh-CN`, `en`, `zh-TW`. Parse with `i18n.Parse`; store on `reqctx.RequestContext.Language`. Read with `reqctx.Lang(ctx)` / `reqctx.Language(ctx)`.
|
||||
|
||||
| Transport | Client sends | Server stores |
|
||||
|-----------|--------------|----------------|
|
||||
| HTTP | `X-Language` if set, else `Accept-Language` | Trace middleware, every request |
|
||||
| TCP | AUTH JSON field `language` | `session.Conn.SetLanguage` for the connection; copied into each command's `RequestContext` |
|
||||
|
||||
Missing or unknown tags → `zh-CN`. `zh` / `zh-Hans` → `zh-CN`. `zh-TW` / `zh-HK` / `zh-Hant` → `zh-TW`. `en*` → `en`.
|
||||
Missing or unknown tags → **`en`** (default). `zh` / `zh-Hans` → `zh-CN`. `zh-TW` / `zh-HK` / `zh-Hant` → `zh-TW`. `en*` → `en`.
|
||||
|
||||
`POST /v1/sms/codes` requires **`X-Language`** explicitly (`i18n.ParseExplicit`); Accept-Language alone is not enough.
|
||||
|
||||
Do not put language in JWT. Rate limit still uses `reqctx.RealIP`, not language.
|
||||
|
||||
|
||||
@ -9,5 +9,6 @@ Before writing code:
|
||||
3. Keep functions within `.golangci.yaml` (`funlen` 100, `lll` 240)
|
||||
4. HTTP and TCP share Application services under `internal/module/{biz}/logic`. Each module is `entity` / `model` (one file per table) / `logic` (one file per function, not per table). Gin handlers only bind and call those services (MVC). REST paths are `/v1/{resource}` grouped by business. Request DTOs use go-playground/validator (`label` / `errmsg` i18n keys). Context must carry `reqctx.RealIP` and the user language (`zh-CN` / `en` / `zh-TW`). Error `msg` and notification copy come from `internal/pkg/i18n`. Table and column COMMENT is required. Message `content` is serialized bytes, not a string.
|
||||
5. After adding or changing `migrations/*.sql`, run `go run ./cmd/migrate -config configs/config.yaml` yourself. Do not ask the user to apply it later.
|
||||
6. Before finishing any code change, run `make lint` and `make test` at the repo root; both must pass. Do not report the task as OK until they do.
|
||||
|
||||
Do not introduce Kafka, K8s, microservices, ConversationSeq, application-layer handshake crypto, or public gnet ports.
|
||||
|
||||
5
Makefile
5
Makefile
@ -8,7 +8,7 @@ LINT ?= golangci-lint
|
||||
CONFIG ?= configs/config.yaml
|
||||
LOGIC_COVER_MIN ?= 65
|
||||
|
||||
.PHONY: all build image test lint fmt vet tidy migrate start run deploy
|
||||
.PHONY: all build image test lint fmt vet tidy migrate start run deploy deploy-rollback
|
||||
|
||||
all: lint test build
|
||||
|
||||
@ -68,3 +68,6 @@ run: build
|
||||
|
||||
deploy:
|
||||
bash deploy/docker/deploy.sh
|
||||
|
||||
deploy-rollback:
|
||||
bash deploy/docker/rollback.sh
|
||||
|
||||
@ -100,7 +100,7 @@ HTTP 与 TCP **应答 Body** 同一套信封。业务成败只看 `code` 字符
|
||||
|
||||
## 登录
|
||||
|
||||
1. (可选)`POST /v1/auth/email-codes` Body `{"email"}`。
|
||||
1. (可选)`POST /v1/sms/codes`,Header **`X-Language` 必填**,Body `{"purpose":1,"target":"…"}`。
|
||||
2. `POST /v1/auth/login` 拿 `access_token` / `refresh_token`。`device_id` 由**客户端生成并持久化**,服务端不分配;写入 JWT `did`。
|
||||
3. 其它 HTTP:`Authorization: Bearer …`,可选 `X-Device-ID`(须等于登录时的 device_id)。
|
||||
4. 续期:`POST /v1/auth/token/refresh`,Body `{"refresh_token"}`,不要带 Bearer。
|
||||
|
||||
@ -96,3 +96,24 @@ object_storage:
|
||||
use_ssl: false
|
||||
presign_ttl: 15m
|
||||
chat_expire_days: 365
|
||||
|
||||
sms:
|
||||
smtp:
|
||||
host: "smtpdm-ap-southeast-1.aliyuncs.com"
|
||||
port: 465
|
||||
username: "noreply@sparkmail.wyrs365.com"
|
||||
password: "gD9UNvSt5ilagfa1"
|
||||
from: "Spark IM <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
|
||||
presign_ttl: 15m
|
||||
chat_expire_days: 365
|
||||
|
||||
sms:
|
||||
smtp:
|
||||
host: "smtpdm-ap-southeast-1.aliyuncs.com"
|
||||
port: 465
|
||||
username: "noreply@sparkmail.wyrs365.com"
|
||||
password: "gD9UNvSt5ilagfa1"
|
||||
from: "Spark IM <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
|
||||
# Build linux/amd64 binaries + runtime image locally, then load them on the ECS host.
|
||||
# Host layout:
|
||||
# /data/spark-server/compose docker-compose.yaml
|
||||
# /data/spark-server/configs config.yaml
|
||||
# /data/spark-server/logs process logs
|
||||
# /data/spark-server/images spark-server.tar.gz
|
||||
# Build linux/amd64 binaries + runtime image locally, deploy to ECS, with rollback on health failure.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
HOST="${SPARK_SSH_HOST:-root@47.236.66.9}"
|
||||
KEY="${SPARK_SSH_KEY:-$HOME/.ssh/spark-ecs}"
|
||||
DATA="${SPARK_DATA_DIR:-/data/spark-server}"
|
||||
IMAGE="${SPARK_IMAGE:-spark-server:latest}"
|
||||
TAR="${SPARK_IMAGE_TAR:-$ROOT/bin/spark-server.tar.gz}"
|
||||
SSH=(ssh -i "$KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$HOST")
|
||||
SCP=(scp -i "$KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new)
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=lib.sh
|
||||
source "$DIR/lib.sh"
|
||||
deploy_lib_init
|
||||
|
||||
need() {
|
||||
command -v "$1" >/dev/null || {
|
||||
echo "need $1 on this machine" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
ROOT="$ROOT"
|
||||
RELEASE="$(deploy_lib_release "$ROOT")"
|
||||
IMAGE_VERSIONED="${IMAGE_BASE}:${RELEASE}"
|
||||
TAR_VERSIONED="$ROOT/bin/spark-server-${RELEASE}.tar.gz"
|
||||
TAR_LATEST="$ROOT/bin/spark-server.tar.gz"
|
||||
|
||||
need go
|
||||
need docker
|
||||
need rsync
|
||||
need ssh
|
||||
need scp
|
||||
deploy_lib_need_tools
|
||||
|
||||
echo "==> release $RELEASE"
|
||||
echo "==> local linux/amd64 binaries + image"
|
||||
make -C "$ROOT" image
|
||||
mkdir -p "$(dirname "$TAR")"
|
||||
docker save "$IMAGE" | gzip -c >"$TAR"
|
||||
echo "image $(docker image inspect "$IMAGE" --format '{{.Size}}') bytes, tar $(wc -c <"$TAR") bytes"
|
||||
make -C "$ROOT" image IMAGE="$IMAGE_LATEST"
|
||||
docker tag "$IMAGE_LATEST" "$IMAGE_VERSIONED"
|
||||
mkdir -p "$(dirname "$TAR_VERSIONED")"
|
||||
docker save "$IMAGE_VERSIONED" | gzip -c >"$TAR_VERSIONED"
|
||||
cp -f "$TAR_VERSIONED" "$TAR_LATEST"
|
||||
echo "image $(docker image inspect "$IMAGE_LATEST" --format '{{.Size}}') bytes, tar $(wc -c <"$TAR_VERSIONED") bytes"
|
||||
|
||||
echo "==> upload"
|
||||
"${SSH[@]}" "mkdir -p $DATA/compose $DATA/configs $DATA/logs $DATA/images"
|
||||
"${SCP[@]}" "$TAR" "$HOST:$DATA/images/spark-server.tar.gz"
|
||||
echo "==> upload (config on host is never overwritten unless SPARK_UPLOAD_CONFIG=1)"
|
||||
"${SSH[@]}" "mkdir -p $DATA/compose $DATA/configs $DATA/logs $DATA/images $DATA/releases"
|
||||
echo "==> stamp rollback point on host (before image load)"
|
||||
deploy_lib_remote_stamp_rollback "$DATA"
|
||||
"${SCP[@]}" "$TAR_VERSIONED" "$HOST:$DATA/images/$(basename "$TAR_VERSIONED")"
|
||||
"${SCP[@]}" "$TAR_LATEST" "$HOST:$DATA/images/spark-server.tar.gz"
|
||||
"${SCP[@]}" "$ROOT/deploy/docker/docker-compose.yaml" "$HOST:$DATA/compose/docker-compose.yaml"
|
||||
"${SCP[@]}" "$ROOT/deploy/docker/config.docker.yaml" "$HOST:$DATA/configs/config.yaml"
|
||||
if [[ "${SPARK_UPLOAD_CONFIG:-0}" == "1" ]]; then
|
||||
echo "==> upload config (SPARK_UPLOAD_CONFIG=1)"
|
||||
"${SCP[@]}" "$ROOT/deploy/docker/config.docker.yaml" "$HOST:$DATA/configs/config.yaml"
|
||||
fi
|
||||
rsync -az -e "ssh -i $KEY -o BatchMode=yes" \
|
||||
"$ROOT/deploy/nginx/host/" "$HOST:/tmp/spark-nginx-host/"
|
||||
|
||||
echo "==> load and start"
|
||||
"${SSH[@]}" "chown -R 10001:10001 $DATA/logs && docker load -i $DATA/images/spark-server.tar.gz"
|
||||
# Replace any stale spark-server container (e.g. old docker run) so compose can recreate.
|
||||
"${SSH[@]}" "docker rm -f spark-server 2>/dev/null || true"
|
||||
"${SSH[@]}" "cd $DATA/compose && docker compose -f docker-compose.yaml up -d --force-recreate spark-server"
|
||||
"${SSH[@]}" "docker exec spark-server /usr/local/bin/spark-migrate -config /app/configs/config.yaml -dir /app/migrations"
|
||||
"${SSH[@]}" "cp /tmp/spark-nginx-host/spark-sni.conf /etc/nginx/stream.d/spark-sni.conf && if [ -f /etc/letsencrypt/live/sparkr.wyrs365.com/fullchain.pem ]; then cp /tmp/spark-nginx-host/sparkr.conf /etc/nginx/conf.d/sparkr.conf; fi && nginx -t && systemctl reload nginx"
|
||||
"${SSH[@]}" "docker ps --filter name=spark-server --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
|
||||
"${SSH[@]}" "curl -fsS http://127.0.0.1:18080/healthz && echo && curl -fsS http://127.0.0.1:18080/readyz && echo"
|
||||
echo "==> load, migrate, start"
|
||||
deploy_lib_remote_load_and_up "$DATA" "$DATA/images/$(basename "$TAR_VERSIONED")" 1 "$RELEASE"
|
||||
|
||||
echo "==> nginx reload"
|
||||
deploy_lib_remote_nginx_reload
|
||||
|
||||
echo "==> health check"
|
||||
if deploy_lib_remote_health 12 5; then
|
||||
deploy_lib_remote_write_manifest "$DATA" "$RELEASE" "$(basename "$TAR_VERSIONED")" "ok"
|
||||
deploy_lib_remote_ps
|
||||
echo "==> deploy OK ($RELEASE)"
|
||||
"${SSH[@]}" "curl -fsS http://127.0.0.1:18080/healthz && echo && curl -fsS http://127.0.0.1:18080/readyz && echo"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "==> health check failed — rolling back app to spark-server:rollback" >&2
|
||||
if deploy_lib_remote_rollback "$DATA" && deploy_lib_remote_health 8 5; then
|
||||
deploy_lib_remote_write_manifest "$DATA" "$RELEASE" "$(basename "$TAR_VERSIONED")" "failed_rolled_back"
|
||||
deploy_lib_remote_ps
|
||||
echo "rolled back to previous image; fix the build and redeploy" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> rollback also failed — check docker logs on host" >&2
|
||||
deploy_lib_remote_ps
|
||||
exit 2
|
||||
|
||||
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"
|
||||
momentlogic "spark-server/internal/module/moment/logic"
|
||||
momentmodel "spark-server/internal/module/moment/model"
|
||||
smslogic "spark-server/internal/module/sms/logic"
|
||||
squarelogic "spark-server/internal/module/square/logic"
|
||||
squaremodel "spark-server/internal/module/square/model"
|
||||
synclogic "spark-server/internal/module/sync/logic"
|
||||
syncmodel "spark-server/internal/module/sync/model"
|
||||
uploadlogic "spark-server/internal/module/upload/logic"
|
||||
@ -160,7 +163,9 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge
|
||||
}
|
||||
app := &App{cfg: cfg, log: log, logClose: logClose, pool: pool, mysql: db, redis: rdb, idgen: idgen, sessions: sessions}
|
||||
probe := &Probe{mysql: db, redis: rdb, objects: objects, pool: pool, accepting: &app.accepting, requireDeps: cfg.Production()}
|
||||
users := wireUsers(idgen, db, rdb, objects)
|
||||
userRepo := wireUserRepo(db)
|
||||
smsSvc := wireSMS(cfg, rdb, userRepo)
|
||||
users := userlogic.NewService(userRepo, smsSvc, idgen, wireUserObjects(objects))
|
||||
login := authlogic.NewLoginService(jwtSvc, users)
|
||||
login.Sessions = sessions
|
||||
var deliverQueue deliverlogic.Queue
|
||||
@ -174,7 +179,8 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge
|
||||
files := wireFiles(cfg, objects, groups)
|
||||
favorites := wireFavorites(idgen, db, objects, chat)
|
||||
moments := wireMoments(idgen, db, objects, friends, users)
|
||||
users.BindAccountCleanup(jwtSvc, sessions, accountCleaners(friends, moments, groups, favorites, inbox, mailbox, chat)...)
|
||||
square := wireSquare(idgen, db, objects, friends, users)
|
||||
users.BindAccountCleanup(jwtSvc, sessions, accountCleaners(friends, moments, square, groups, favorites, inbox, mailbox, chat)...)
|
||||
adminSvc, adminTokens := wireAdmin(cfg, db, groups, idgen)
|
||||
app.tcp = tcp.NewServer(log, pool, sessions, &tcp.Options{
|
||||
Addr: cfg.Server.TCP.Addr,
|
||||
@ -195,25 +201,38 @@ func finishApp(cfg *Config, log *zap.Logger, logClose func() error, idgen *id.Ge
|
||||
Messages: messages,
|
||||
Sync: syncSvc,
|
||||
})
|
||||
app.http = httpx.New(log, cfg.Server.HTTP.Addr, &httpx.EngineDeps{
|
||||
Auth: jwtSvc,
|
||||
Login: login,
|
||||
Users: users,
|
||||
Friends: friends,
|
||||
Groups: groups,
|
||||
Files: files,
|
||||
Favorites: favorites,
|
||||
Moments: moments,
|
||||
Messages: messages,
|
||||
Sync: syncSvc,
|
||||
Admin: adminSvc,
|
||||
AdminTokens: adminTokens,
|
||||
Health: probe,
|
||||
AuthLimiter: authLimit,
|
||||
})
|
||||
app.http = httpx.New(log, cfg.Server.HTTP.Addr, httpEngineDeps(
|
||||
jwtSvc, login, smsSvc, users, friends, groups, files, favorites, moments, square, messages, syncSvc, adminSvc, adminTokens, probe, authLimit,
|
||||
))
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func httpEngineDeps(
|
||||
jwtSvc *authlogic.JWT,
|
||||
login *authlogic.LoginService,
|
||||
sms *smslogic.Service,
|
||||
users *userlogic.Service,
|
||||
friends *friendlogic.Service,
|
||||
groups *grouplogic.Service,
|
||||
files *uploadlogic.Service,
|
||||
favorites *favlogic.Service,
|
||||
moments *momentlogic.Service,
|
||||
square *squarelogic.Service,
|
||||
messages *msglogic.Service,
|
||||
syncSvc *synclogic.Service,
|
||||
adminSvc *adminlogic.Service,
|
||||
adminTokens *adminlogic.TokenService,
|
||||
probe *Probe,
|
||||
authLimit *ratelimit.Limiter,
|
||||
) *httpx.EngineDeps {
|
||||
return &httpx.EngineDeps{
|
||||
Auth: jwtSvc, Login: login, SMS: sms, Users: users, Friends: friends, Groups: groups,
|
||||
Files: files, Favorites: favorites, Moments: moments, Square: square,
|
||||
Messages: messages, Sync: syncSvc, Admin: adminSvc, AdminTokens: adminTokens,
|
||||
Health: probe, AuthLimiter: authLimit,
|
||||
}
|
||||
}
|
||||
|
||||
func wireAdmin(cfg *Config, db *mysql.DB, groups *grouplogic.Service, idgen *id.Generator) (*adminlogic.Service, *adminlogic.TokenService) {
|
||||
tokens := adminlogic.NewTokenService(cfg.Admin.JWTSecret)
|
||||
var repo *adminmodel.Repo
|
||||
@ -234,24 +253,18 @@ func loadTLS(t TLSConfig) (*tls.Config, error) {
|
||||
return &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{cert}}, nil
|
||||
}
|
||||
|
||||
func wireUsers(idgen *id.Generator, db *mysql.DB, rdb *redisx.Client, store *objectstore.Store) *userlogic.Service {
|
||||
var repo userlogic.Repository
|
||||
func wireUserRepo(db *mysql.DB) userlogic.Repository {
|
||||
if db != nil && db.Ready() {
|
||||
repo = usermodel.NewRepo(db.Gorm())
|
||||
} else {
|
||||
repo = usermodel.NewMemoryRepo()
|
||||
return usermodel.NewRepo(db.Gorm())
|
||||
}
|
||||
var codes userlogic.CodeStore
|
||||
if rdb != nil && rdb.Ready() {
|
||||
codes = usermodel.NewRedisCodes(rdb.RDB())
|
||||
} else {
|
||||
codes = usermodel.NewMemoryCodes()
|
||||
}
|
||||
var objects userlogic.Objects
|
||||
return usermodel.NewMemoryRepo()
|
||||
}
|
||||
|
||||
func wireUserObjects(store *objectstore.Store) userlogic.Objects {
|
||||
if store != nil && store.Ready() {
|
||||
objects = store
|
||||
return store
|
||||
}
|
||||
return userlogic.NewService(repo, codes, idgen, objects)
|
||||
return nil
|
||||
}
|
||||
|
||||
type chatStores struct {
|
||||
@ -324,6 +337,23 @@ func wireFavorites(idgen *id.Generator, db *mysql.DB, store *objectstore.Store,
|
||||
return favlogic.NewService(repo, chat.msgs, chat.chats, objects, idgen)
|
||||
}
|
||||
|
||||
func wireSquare(idgen *id.Generator, db *mysql.DB, store *objectstore.Store, friends *friendlogic.Service, users *userlogic.Service) *squarelogic.Service {
|
||||
if idgen == nil || friends == nil || users == nil {
|
||||
return nil
|
||||
}
|
||||
var repo squarelogic.Repository
|
||||
if db != nil && db.Ready() {
|
||||
repo = squaremodel.NewRepo(db.Gorm())
|
||||
} else {
|
||||
repo = squaremodel.NewMemoryRepo()
|
||||
}
|
||||
var objects squarelogic.Objects
|
||||
if store != nil && store.Ready() {
|
||||
objects = store
|
||||
}
|
||||
return squarelogic.NewService(repo, friends, users, objects, idgen)
|
||||
}
|
||||
|
||||
func wireMoments(idgen *id.Generator, db *mysql.DB, store *objectstore.Store, friends *friendlogic.Service, users *userlogic.Service) *momentlogic.Service {
|
||||
if idgen == nil || friends == nil || users == nil {
|
||||
return nil
|
||||
@ -384,6 +414,7 @@ func wireMessages(idgen *id.Generator, friends *friendlogic.Service, groups *gro
|
||||
func accountCleaners(
|
||||
friends *friendlogic.Service,
|
||||
moments *momentlogic.Service,
|
||||
square *squarelogic.Service,
|
||||
groups *grouplogic.Service,
|
||||
favorites *favlogic.Service,
|
||||
inbox inboxlogic.Store,
|
||||
@ -397,6 +428,9 @@ func accountCleaners(
|
||||
if moments != nil {
|
||||
out = append(out, moments)
|
||||
}
|
||||
if square != nil {
|
||||
out = append(out, square)
|
||||
}
|
||||
if groups != nil {
|
||||
out = append(out, groups)
|
||||
}
|
||||
|
||||
@ -25,6 +25,7 @@ type Config struct {
|
||||
RateLimit RateLimitConfig `yaml:"rate_limit"`
|
||||
Message MessageConfig `yaml:"message"`
|
||||
ObjectStorage ObjectStorageConfig `yaml:"object_storage"`
|
||||
SMS SMSConfig `yaml:"sms"`
|
||||
}
|
||||
|
||||
// AdminConfig is popadmin JWT validation (separate from client jwt).
|
||||
@ -140,6 +141,37 @@ type MessageConfig struct {
|
||||
MsgIDExpireMS int64 `yaml:"msgid_expire_ms"`
|
||||
}
|
||||
|
||||
type SMSConfig struct {
|
||||
SMTP SMTPConfig `yaml:"smtp"`
|
||||
AliyunSMS AliyunSMSConfig `yaml:"aliyun_sms"`
|
||||
}
|
||||
|
||||
type SMTPConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
From string `yaml:"from"`
|
||||
}
|
||||
|
||||
type AliyunSMSConfig struct {
|
||||
AccessKeyID string `yaml:"access_key_id"`
|
||||
AccessKeySecret string `yaml:"access_key_secret"`
|
||||
SignName string `yaml:"sign_name"`
|
||||
Templates AliyunSMSTemplatesConfig `yaml:"templates"`
|
||||
}
|
||||
|
||||
type AliyunSMSTemplatesConfig struct {
|
||||
Login AliyunCarrierTemplates `yaml:"login"`
|
||||
AccountDelete AliyunCarrierTemplates `yaml:"account_delete"`
|
||||
}
|
||||
|
||||
type AliyunCarrierTemplates struct {
|
||||
Mobile string `yaml:"mobile"`
|
||||
Unicom string `yaml:"unicom"`
|
||||
Telecom string `yaml:"telecom"`
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
Secret string `yaml:"secret"`
|
||||
Issuer string `yaml:"issuer"`
|
||||
@ -254,6 +286,15 @@ func applyDefaults(cfg *Config) {
|
||||
if v := os.Getenv("SPARK_S3_BUCKET"); v != "" {
|
||||
cfg.ObjectStorage.Bucket = v
|
||||
}
|
||||
if v := os.Getenv("SPARK_SMTP_PASSWORD"); v != "" {
|
||||
cfg.SMS.SMTP.Password = v
|
||||
}
|
||||
if v := os.Getenv("SPARK_ALIYUN_SMS_ACCESS_KEY_ID"); v != "" {
|
||||
cfg.SMS.AliyunSMS.AccessKeyID = v
|
||||
}
|
||||
if v := os.Getenv("SPARK_ALIYUN_SMS_ACCESS_KEY_SECRET"); v != "" {
|
||||
cfg.SMS.AliyunSMS.AccessKeySecret = v
|
||||
}
|
||||
if cfg.ObjectStorage.Bucket == "" {
|
||||
cfg.ObjectStorage.Bucket = "spark"
|
||||
}
|
||||
@ -302,5 +343,11 @@ func (cfg *Config) validate() error {
|
||||
return fmt.Errorf("prod: object_storage access_key and secret_key are required")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(cfg.SMS.SMTP.Host) == "" {
|
||||
return fmt.Errorf("prod: sms.smtp.host is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.SMS.SMTP.Username) == "" || strings.TrimSpace(cfg.SMS.SMTP.Password) == "" {
|
||||
return fmt.Errorf("prod: sms.smtp.username and password are required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -36,6 +36,7 @@ func TestParseEnvAndProductionValidate(t *testing.T) {
|
||||
t.Fatal("prod default admin jwt")
|
||||
}
|
||||
prod.Admin.JWTSecret = "production-admin-jwt-secret-not-default"
|
||||
prod.SMS = SMSConfig{SMTP: SMTPConfig{Host: "smtpdm.aliyun.com", Port: 465, Username: "noreply@sparkmail.wyrs365.com", Password: "test"}}
|
||||
if err := prod.normalize(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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"
|
||||
userentity "spark-server/internal/module/user/entity"
|
||||
userlogic "spark-server/internal/module/user/logic"
|
||||
"spark-server/internal/pkg/errs"
|
||||
"spark-server/internal/pkg/i18n"
|
||||
"spark-server/internal/pkg/reqctx"
|
||||
@ -15,7 +14,6 @@ import (
|
||||
// Verifier checks email OTP and records the login device. The user module owns the implementation.
|
||||
type Verifier interface {
|
||||
AuthenticateEmail(ctx context.Context, email, verifyCode string, device *userentity.LoginDevice) (*userentity.User, error)
|
||||
SendEmailCode(ctx context.Context, email string) error
|
||||
}
|
||||
|
||||
// LoginInput is the HTTP/TCP-shared login body. Validation tags drive client-visible messages.
|
||||
@ -28,15 +26,6 @@ type LoginInput struct {
|
||||
DeviceName string `json:"device_name" validate:"omitempty,max=128" label:"field.device_name"`
|
||||
}
|
||||
|
||||
// SendCodeInput requests an email OTP. Test accounts do not need this.
|
||||
type SendCodeInput struct {
|
||||
Email string `json:"email" validate:"required,email" label:"field.email" errmsg:"required:validate.login.email_required;email:validate.login.email_invalid"`
|
||||
}
|
||||
|
||||
type SentCode struct {
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
|
||||
type RefreshInput struct {
|
||||
RefreshToken string `json:"refresh_token" validate:"required" label:"field.refresh_token" errmsg:"required:validate.login.refresh_required"`
|
||||
}
|
||||
@ -82,19 +71,6 @@ func (s *LoginService) Login(ctx context.Context, in *LoginInput) (*authentity.I
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
func (s *LoginService) SendEmailCode(ctx context.Context, in SendCodeInput) (*SentCode, error) {
|
||||
if s == nil || s.Users == nil {
|
||||
return nil, errs.ErrAvailable
|
||||
}
|
||||
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.Users.SendEmailCode(ctx, in.Email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SentCode{ExpiresIn: userlogic.EmailCodeTTLSeconds}, nil
|
||||
}
|
||||
|
||||
func (s *LoginService) Refresh(ctx context.Context, in RefreshInput) (*authentity.IssuedToken, error) {
|
||||
if s == nil || s.Issuer == nil {
|
||||
return nil, errs.ErrUnauthorized
|
||||
|
||||
@ -5,6 +5,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"spark-server/internal/infrastructure/id"
|
||||
smslogic "spark-server/internal/module/sms/logic"
|
||||
smsmodel "spark-server/internal/module/sms/model"
|
||||
userlogic "spark-server/internal/module/user/logic"
|
||||
usermodel "spark-server/internal/module/user/model"
|
||||
"spark-server/internal/pkg/errs"
|
||||
@ -21,7 +24,7 @@ func TestLoginServiceValidatesAndIssues(t *testing.T) {
|
||||
|
||||
_, err = svc.Login(ctx, &LoginInput{Email: "test01@spark.com"})
|
||||
app, ok := errs.AsAppError(err)
|
||||
if !ok || app.Code != errs.ErrInvalidArgument.Code || !strings.Contains(app.Msg, "请填写验证码") {
|
||||
if !ok || app.Code != errs.ErrInvalidArgument.Code || !strings.Contains(app.Msg, "Please enter the verification code") {
|
||||
t.Fatalf("validate %+v", err)
|
||||
}
|
||||
|
||||
@ -41,7 +44,7 @@ func TestLoginServiceValidatesAndIssues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginServiceSendRefreshLogout(t *testing.T) {
|
||||
func TestLoginServiceRefreshLogout(t *testing.T) {
|
||||
j, err := NewJWT(&Options{Secret: "test-secret-test-secret", Issuer: "spark-im"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@ -51,10 +54,6 @@ func TestLoginServiceSendRefreshLogout(t *testing.T) {
|
||||
svc := NewLoginService(j, users)
|
||||
svc.Sessions = kicker
|
||||
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{RealIP: "203.0.113.10"})
|
||||
sent, err := svc.SendEmailCode(ctx, SendCodeInput{Email: "test01@spark.com"})
|
||||
if err != nil || sent == nil || sent.ExpiresIn != userlogic.EmailCodeTTLSeconds {
|
||||
t.Fatalf("code %+v %v", sent, err)
|
||||
}
|
||||
tok, err := svc.Login(ctx, &LoginInput{Email: "test01@spark.com", VerifyCode: "any", DeviceID: 7, DeviceType: "ios"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@ -74,9 +73,6 @@ func TestLoginServiceSendRefreshLogout(t *testing.T) {
|
||||
if kicker.n != 1 {
|
||||
t.Fatalf("kick %d", kicker.n)
|
||||
}
|
||||
if _, err := svc.SendEmailCode(ctx, SendCodeInput{}); err == nil {
|
||||
t.Fatal("empty email")
|
||||
}
|
||||
if _, err := (*LoginService)(nil).Refresh(ctx, RefreshInput{RefreshToken: "x"}); !errors.Is(err, errs.ErrUnauthorized) {
|
||||
t.Fatalf("nil refresh %v", err)
|
||||
}
|
||||
@ -122,8 +118,13 @@ func TestLoginServiceRecordsContextDevice(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := usermodel.NewMemoryRepo()
|
||||
ids := seqIDs{n: 1000}
|
||||
users := userlogic.NewService(repo, usermodel.NewMemoryCodes(), &ids, nil)
|
||||
idgen, err := id.New(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := smsmodel.NewMemoryStore()
|
||||
smsSvc := smslogic.NewService(store, nil, nil, nil)
|
||||
users := userlogic.NewService(repo, smsSvc, idgen, nil)
|
||||
svc := NewLoginService(j, users)
|
||||
ctx := reqctx.With(t.Context(), &reqctx.RequestContext{
|
||||
RealIP: "198.51.100.9", Language: "en", AppVersion: "2.1.0",
|
||||
@ -142,10 +143,3 @@ func TestLoginServiceRecordsContextDevice(t *testing.T) {
|
||||
t.Fatalf("%+v", dev)
|
||||
}
|
||||
}
|
||||
|
||||
type seqIDs struct{ n int64 }
|
||||
|
||||
func (s *seqIDs) NextID() (int64, error) {
|
||||
s.n++
|
||||
return s.n, nil
|
||||
}
|
||||
|
||||
@ -43,5 +43,3 @@ func (s StaticUser) AuthenticateEmail(_ context.Context, email, verifyCode strin
|
||||
}
|
||||
return &userentity.User{ID: s.UserID, SparkID: sparkID, Status: userentity.StatusActive}, nil
|
||||
}
|
||||
|
||||
func (s StaticUser) SendEmailCode(context.Context, string) error { return nil }
|
||||
|
||||
@ -16,7 +16,7 @@ import (
|
||||
msgmodel "spark-server/internal/module/message/model"
|
||||
userentity "spark-server/internal/module/user/entity"
|
||||
userlogic "spark-server/internal/module/user/logic"
|
||||
usermodel "spark-server/internal/module/user/model"
|
||||
"spark-server/internal/module/user/logictest"
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
@ -37,7 +37,7 @@ func testFriends(t *testing.T) (*Service, *userlogic.Service, *captureGreeter) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil)
|
||||
users := logictest.Users(ids, nil)
|
||||
g := &captureGreeter{}
|
||||
return NewService(friendmodel.NewMemoryRepo(), users, ids, g), users, g
|
||||
}
|
||||
|
||||
@ -20,7 +20,7 @@ import (
|
||||
msgmodel "spark-server/internal/module/message/model"
|
||||
userentity "spark-server/internal/module/user/entity"
|
||||
userlogic "spark-server/internal/module/user/logic"
|
||||
usermodel "spark-server/internal/module/user/model"
|
||||
"spark-server/internal/module/user/logictest"
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
@ -46,7 +46,7 @@ func testGroupsStore(t *testing.T, objects testObjects) (*Service, *friendlogic.
|
||||
userObjs = objects
|
||||
groupObjs = objects
|
||||
}
|
||||
users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, userObjs)
|
||||
users := logictest.Users(ids, userObjs)
|
||||
friends := friendlogic.NewService(
|
||||
friendmodel.NewMemoryRepo(), users, ids,
|
||||
friendlogic.NewGreeter(convlogic.NewP2PService(convmodel.NewMemoryP2P(), ids), msgmodel.NewMemoryRepo(), ids, delivermodel.NewMemoryQueue()),
|
||||
|
||||
@ -18,7 +18,7 @@ import (
|
||||
msgmodel "spark-server/internal/module/message/model"
|
||||
userentity "spark-server/internal/module/user/entity"
|
||||
userlogic "spark-server/internal/module/user/logic"
|
||||
usermodel "spark-server/internal/module/user/model"
|
||||
"spark-server/internal/module/user/logictest"
|
||||
"spark-server/internal/pkg/errs"
|
||||
"spark-server/internal/pkg/reqctx"
|
||||
)
|
||||
@ -29,7 +29,7 @@ func testMessageSvc(t *testing.T) (*Service, *userlogic.Service, *friendlogic.Se
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil)
|
||||
users := logictest.Users(ids, nil)
|
||||
chats := convmodel.NewMemoryP2P()
|
||||
p2p := convlogic.NewP2PService(chats, ids)
|
||||
q := delivermodel.NewMemoryQueue()
|
||||
|
||||
@ -34,4 +34,6 @@ type Catalog struct {
|
||||
MomentStatus []Item `json:"moment_status"`
|
||||
MomentNoticeKind []Item `json:"moment_notice_kind"`
|
||||
MomentVisibleDays []Item `json:"moment_visible_days"`
|
||||
VerificationPurpose []Item `json:"verification_purpose"`
|
||||
PhoneCarrier []Item `json:"phone_carrier"`
|
||||
}
|
||||
|
||||
@ -35,6 +35,8 @@ func Catalog(lang i18n.Lang) *entity.Catalog {
|
||||
MomentStatus: momentStatus(lang),
|
||||
MomentNoticeKind: momentNoticeKind(lang),
|
||||
MomentVisibleDays: momentVisibleDays(lang),
|
||||
VerificationPurpose: verificationPurpose(lang),
|
||||
PhoneCarrier: phoneCarrier(lang),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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, "unauthorized", errs.ErrUnauthorized),
|
||||
code(lang, "not_authenticated", errs.ErrNotAuthenticated),
|
||||
code(lang, "verify_code_invalid", errs.ErrVerifyCodeInvalid),
|
||||
code(lang, "forbidden", errs.ErrForbidden),
|
||||
code(lang, "not_found", errs.ErrNotFound),
|
||||
code(lang, "media_expired", errs.ErrMediaExpired),
|
||||
|
||||
@ -14,7 +14,7 @@ import (
|
||||
momentmodel "spark-server/internal/module/moment/model"
|
||||
userentity "spark-server/internal/module/user/entity"
|
||||
userlogic "spark-server/internal/module/user/logic"
|
||||
usermodel "spark-server/internal/module/user/model"
|
||||
"spark-server/internal/module/user/logictest"
|
||||
"spark-server/internal/pkg/errs"
|
||||
"spark-server/internal/pkg/reqctx"
|
||||
)
|
||||
@ -34,7 +34,7 @@ func testMoment(t *testing.T) (*Service, *friendlogic.Service, *userlogic.Servic
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil)
|
||||
users := logictest.Users(ids, nil)
|
||||
friends := friendlogic.NewService(friendmodel.NewMemoryRepo(), users, ids, nil)
|
||||
svc := NewService(momentmodel.NewMemoryRepo(), friends, users, &memObj{}, ids)
|
||||
return svc, friends, users
|
||||
|
||||
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"
|
||||
PurposeMomentVideo = "moment_video"
|
||||
PurposeMomentCover = "moment_cover"
|
||||
PurposeSquareImage = "square_image"
|
||||
PurposeSquareVideo = "square_video"
|
||||
|
||||
PrefixPerm = "perm/"
|
||||
PrefixChat = "chat/"
|
||||
@ -33,6 +35,8 @@ const (
|
||||
PrefixMomentImage = "perm/moment/image/"
|
||||
PrefixMomentVideo = "perm/moment/video/"
|
||||
PrefixMomentCover = "perm/moment/cover/"
|
||||
PrefixSquareImage = "perm/square/image/"
|
||||
PrefixSquareVideo = "perm/square/video/"
|
||||
|
||||
MethodPUT = "PUT"
|
||||
MethodGET = "GET"
|
||||
@ -107,6 +111,10 @@ func BuildObjectKey(purpose string, ownerID int64, name, day, ext string) string
|
||||
return fmt.Sprintf("%s%s/%s.%s", PrefixMomentVideo, day, name, ext)
|
||||
case PurposeMomentCover:
|
||||
return fmt.Sprintf("%s%s/%s.%s", PrefixMomentCover, day, name, ext)
|
||||
case PurposeSquareImage:
|
||||
return fmt.Sprintf("%s%s/%s.%s", PrefixSquareImage, day, name, ext)
|
||||
case PurposeSquareVideo:
|
||||
return fmt.Sprintf("%s%s/%s.%s", PrefixSquareVideo, day, name, ext)
|
||||
default:
|
||||
return fmt.Sprintf("%s%s/%s/%s.%s", PrefixChat, chatKind(purpose), day, name, ext)
|
||||
}
|
||||
@ -153,6 +161,8 @@ var specs = map[string]Spec{
|
||||
PurposeMomentCover: {Purpose: PurposeMomentCover, MaxSize: maxAvatar, MIMEs: imageMIMEs},
|
||||
PurposeMomentImage: {Purpose: PurposeMomentImage, MaxSize: maxMoment, MIMEs: momentImageMIMEs},
|
||||
PurposeMomentVideo: {Purpose: PurposeMomentVideo, MaxSize: maxVideo, MIMEs: videoMIMEs},
|
||||
PurposeSquareImage: {Purpose: PurposeSquareImage, MaxSize: maxMoment, MIMEs: momentImageMIMEs},
|
||||
PurposeSquareVideo: {Purpose: PurposeSquareVideo, MaxSize: maxVideo, MIMEs: videoMIMEs},
|
||||
PurposeChatImage: {Purpose: PurposeChatImage, MaxSize: maxImage, MIMEs: imageMIMEs},
|
||||
PurposeChatGIF: {Purpose: PurposeChatGIF, MaxSize: maxGIF, MIMEs: gifMIMEs},
|
||||
PurposeChatVoice: {Purpose: PurposeChatVoice, MaxSize: maxVoice, MIMEs: voiceMIMEs},
|
||||
|
||||
@ -13,7 +13,7 @@ import (
|
||||
|
||||
// CreateInput requests a single-object Presigned PUT. The client does not choose the key.
|
||||
type CreateInput struct {
|
||||
Purpose string `json:"purpose" validate:"required,oneof=avatar group_avatar chat_image chat_voice chat_video chat_gif chat_file moment_image moment_video moment_cover" label:"field.purpose" errmsg:"oneof:validate.upload.purpose_invalid"`
|
||||
Purpose string `json:"purpose" validate:"required" label:"field.purpose" errmsg:"required:validate.upload.purpose_required"`
|
||||
Mime string `json:"mime" validate:"required,max=128" label:"field.mime" errmsg:"required:validate.upload.mime_required;max:validate.upload.mime_invalid"`
|
||||
Size int64 `json:"size" validate:"required,gt=0" label:"field.size" errmsg:"required:validate.upload.size_required;gt:validate.upload.size_invalid"`
|
||||
GroupID int64 `json:"group_id" validate:"omitempty,gt=0" label:"field.group_id"`
|
||||
|
||||
@ -6,10 +6,18 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
smsentity "spark-server/internal/module/sms/entity"
|
||||
"spark-server/internal/module/user/entity"
|
||||
"spark-server/internal/pkg/errs"
|
||||
"spark-server/internal/pkg/i18n"
|
||||
"spark-server/internal/pkg/reqctx"
|
||||
)
|
||||
|
||||
// DeleteAccountInput confirms account cancellation with an email OTP (purpose=account_delete).
|
||||
type DeleteAccountInput struct {
|
||||
VerifyCode string `json:"verify_code" validate:"omitempty" label:"field.verify_code"`
|
||||
}
|
||||
|
||||
// UserDataCleaner removes a canceled user's rows in another module.
|
||||
type UserDataCleaner interface {
|
||||
PurgeUser(ctx context.Context, userID int64) error
|
||||
@ -49,7 +57,7 @@ func (s *Service) BindAccountCleanup(tokens TokenRevoker, sessions SessionDroppe
|
||||
s.cleaners = append(s.cleaners[:0], cleaners...)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteAccount(ctx context.Context, userID int64) error {
|
||||
func (s *Service) DeleteAccount(ctx context.Context, userID int64, in DeleteAccountInput) error {
|
||||
if !s.ready() || userID <= 0 {
|
||||
return errs.ErrUnauthorized
|
||||
}
|
||||
@ -61,6 +69,9 @@ func (s *Service) DeleteAccount(ctx context.Context, userID int64) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.verifyDeleteCode(ctx, email, in.VerifyCode); err != nil {
|
||||
return err
|
||||
}
|
||||
s.revokeAccess(ctx, userID)
|
||||
if err := s.purgeRelated(ctx, userID); err != nil {
|
||||
return err
|
||||
@ -69,12 +80,26 @@ func (s *Service) DeleteAccount(ctx context.Context, userID int64) error {
|
||||
return err
|
||||
}
|
||||
s.deleteAvatarObject(ctx, u.Avatar)
|
||||
if email != "" && s.codes != nil {
|
||||
_ = s.codes.Del(ctx, email)
|
||||
if email != "" && s.otp != nil {
|
||||
s.otp.ClearTarget(ctx, smsentity.ChannelEmail, email)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) verifyDeleteCode(ctx context.Context, email, code string) error {
|
||||
email = entity.NormalizeEmail(email)
|
||||
if email == "" || entity.IsTestEmail(email) {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(code) == "" {
|
||||
return errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginCodeRequired, nil))
|
||||
}
|
||||
if s.otp == nil {
|
||||
return errs.ErrAvailable
|
||||
}
|
||||
return s.otp.CheckAndConsume(ctx, smsentity.ChannelEmail, smsentity.PurposeAccountDelete, email, code)
|
||||
}
|
||||
|
||||
func (s *Service) lookupEmail(ctx context.Context, userID int64) (string, error) {
|
||||
email, err := s.repo.GetEmail(ctx, userID)
|
||||
if err == nil {
|
||||
|
||||
@ -6,6 +6,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"spark-server/internal/infrastructure/id"
|
||||
smslogic "spark-server/internal/module/sms/logic"
|
||||
smsmodel "spark-server/internal/module/sms/model"
|
||||
friendlogic "spark-server/internal/module/friend/logic"
|
||||
friendmodel "spark-server/internal/module/friend/model"
|
||||
momentlogic "spark-server/internal/module/moment/logic"
|
||||
@ -24,7 +26,7 @@ func TestDeleteAccountArchivesAndAllowsReregister(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.DeleteAccount(ctx, u.ID); err != nil {
|
||||
if err := svc.DeleteAccount(ctx, u.ID, DeleteAccountInput{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repo.GetByID(ctx, u.ID); !errors.Is(err, errs.ErrNotFound) {
|
||||
@ -52,7 +54,9 @@ func TestDeleteAccountPurgesFriendsAndMoments(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := model.NewMemoryRepo()
|
||||
users := NewService(repo, model.NewMemoryCodes(), ids, nil)
|
||||
store := smsmodel.NewMemoryStore()
|
||||
smsSvc := smslogic.NewService(store, nil, nil, nil)
|
||||
users := NewService(repo, smsSvc, ids, nil)
|
||||
friends := friendlogic.NewService(friendmodel.NewMemoryRepo(), users, ids, nil)
|
||||
moments := momentlogic.NewService(momentmodel.NewMemoryRepo(), friends, users, nil, ids)
|
||||
users.BindAccountCleanup(nil, nil, friends, moments)
|
||||
@ -77,7 +81,7 @@ func TestDeleteAccountPurgesFriendsAndMoments(t *testing.T) {
|
||||
if err != nil || pub == nil {
|
||||
t.Fatalf("publish %+v %v", pub, err)
|
||||
}
|
||||
if err := users.DeleteAccount(ctx, a.ID); err != nil {
|
||||
if err := users.DeleteAccount(ctx, a.ID, DeleteAccountInput{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ok, err := friends.AreFriends(ctx, a.ID, b.ID)
|
||||
|
||||
@ -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 (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
smsentity "spark-server/internal/module/sms/entity"
|
||||
"spark-server/internal/module/user/entity"
|
||||
"spark-server/internal/pkg/errs"
|
||||
"spark-server/internal/pkg/i18n"
|
||||
@ -56,15 +56,10 @@ func (s *Service) checkVerifyCode(ctx context.Context, email, verifyCode string)
|
||||
if entity.IsTestEmail(email) {
|
||||
return nil
|
||||
}
|
||||
want, err := s.codes.Get(ctx, email)
|
||||
if err != nil {
|
||||
return err
|
||||
if s.otp == nil {
|
||||
return errs.ErrAvailable
|
||||
}
|
||||
if want == "" || subtle.ConstantTimeCompare([]byte(want), []byte(verifyCode)) != 1 {
|
||||
return errs.ErrUnauthorized
|
||||
}
|
||||
_ = s.codes.Del(ctx, email)
|
||||
return nil
|
||||
return s.otp.CheckAndConsume(ctx, smsentity.ChannelEmail, smsentity.PurposeLogin, email, verifyCode)
|
||||
}
|
||||
|
||||
func (s *Service) getOrCreate(ctx context.Context, email string) (*entity.User, error) {
|
||||
|
||||
@ -4,12 +4,16 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"spark-server/internal/infrastructure/id"
|
||||
smslogic "spark-server/internal/module/sms/logic"
|
||||
smsmodel "spark-server/internal/module/sms/model"
|
||||
smsentity "spark-server/internal/module/sms/entity"
|
||||
"spark-server/internal/module/user/entity"
|
||||
"spark-server/internal/module/user/model"
|
||||
"spark-server/internal/pkg/errs"
|
||||
@ -111,10 +115,10 @@ func TestAuthenticateNonTestNeedsCode(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
device := &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceWeb}
|
||||
_, err := svc.AuthenticateEmail(ctx, "ada@example.com", "123456", device)
|
||||
if !errors.Is(err, errs.ErrUnauthorized) {
|
||||
if !errors.Is(err, errs.ErrVerifyCodeInvalid) {
|
||||
t.Fatalf("no code: %v", err)
|
||||
}
|
||||
if err := codes.Put(ctx, "ada@example.com", "123456", 60); err != nil {
|
||||
if err := codes.Put(ctx, fmt.Sprintf("%d:%d:%s", smsentity.ChannelEmail, smsentity.PurposeLogin, "ada@example.com"), "123456", 60); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, err := svc.AuthenticateEmail(ctx, "ada@example.com", "123456", device)
|
||||
@ -125,7 +129,7 @@ func TestAuthenticateNonTestNeedsCode(t *testing.T) {
|
||||
t.Fatal(u)
|
||||
}
|
||||
_, err = svc.AuthenticateEmail(ctx, "ada@example.com", "123456", device)
|
||||
if !errors.Is(err, errs.ErrUnauthorized) {
|
||||
if !errors.Is(err, errs.ErrVerifyCodeInvalid) {
|
||||
t.Fatalf("consumed: %v", err)
|
||||
}
|
||||
}
|
||||
@ -157,7 +161,9 @@ func TestRegisterFallsBackToDataURIWithoutObjectStore(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewService(model.NewMemoryRepo(), model.NewMemoryCodes(), ids, nil)
|
||||
store := smsmodel.NewMemoryStore()
|
||||
smsSvc := smslogic.NewService(store, nil, nil, nil)
|
||||
svc := NewService(model.NewMemoryRepo(), smsSvc, ids, nil)
|
||||
device := &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS}
|
||||
u, err := svc.AuthenticateEmail(t.Context(), "test09@spark.com", "1", device)
|
||||
if err != nil {
|
||||
@ -173,7 +179,9 @@ func TestRegisterFallsBackWhenObjectPutFails(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewService(model.NewMemoryRepo(), model.NewMemoryCodes(), ids, failObjects{})
|
||||
store := smsmodel.NewMemoryStore()
|
||||
smsSvc := smslogic.NewService(store, nil, nil, nil)
|
||||
svc := NewService(model.NewMemoryRepo(), smsSvc, ids, failObjects{})
|
||||
device := &entity.LoginDevice{DeviceID: 1, DeviceType: entity.DeviceIOS}
|
||||
u, err := svc.AuthenticateEmail(t.Context(), "test09@spark.com", "1", device)
|
||||
if err != nil {
|
||||
|
||||
@ -37,7 +37,7 @@ func TestUpdateSparkID(t *testing.T) {
|
||||
}
|
||||
_, err = svc.UpdateSparkID(ctx, u.ID, "1bad")
|
||||
app, ok := errs.AsAppError(err)
|
||||
if !ok || app.Code != errs.ErrInvalidArgument.Code || !strings.Contains(app.Msg, "字母开头") {
|
||||
if !ok || app.Code != errs.ErrInvalidArgument.Code || !strings.Contains(app.Msg, "letter") {
|
||||
t.Fatalf("invalid %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
smsentity "spark-server/internal/module/sms/entity"
|
||||
"spark-server/internal/module/user/entity"
|
||||
)
|
||||
|
||||
@ -37,10 +38,16 @@ type Objects interface {
|
||||
Put(ctx context.Context, key string, body []byte, contentType string) error
|
||||
}
|
||||
|
||||
// OTP checks and clears verification codes issued by the sms module.
|
||||
type OTP interface {
|
||||
CheckAndConsume(ctx context.Context, ch smsentity.Channel, purpose smsentity.Purpose, target, code string) error
|
||||
ClearTarget(ctx context.Context, ch smsentity.Channel, target string)
|
||||
}
|
||||
|
||||
// Service is the user application use-case (HTTP and TCP share it).
|
||||
type Service struct {
|
||||
repo Repository
|
||||
codes CodeStore
|
||||
otp OTP
|
||||
ids IDGen
|
||||
objects Objects
|
||||
tokens TokenRevoker
|
||||
@ -48,8 +55,8 @@ type Service struct {
|
||||
cleaners []UserDataCleaner
|
||||
}
|
||||
|
||||
func NewService(repo Repository, codes CodeStore, ids IDGen, objects Objects) *Service {
|
||||
return &Service{repo: repo, codes: codes, ids: ids, objects: objects}
|
||||
func NewService(repo Repository, otp OTP, ids IDGen, objects Objects) *Service {
|
||||
return &Service{repo: repo, otp: otp, ids: ids, objects: objects}
|
||||
}
|
||||
|
||||
func (s *Service) ready() bool {
|
||||
|
||||
@ -5,18 +5,21 @@ import (
|
||||
"testing"
|
||||
|
||||
"spark-server/internal/infrastructure/id"
|
||||
smslogic "spark-server/internal/module/sms/logic"
|
||||
smsmodel "spark-server/internal/module/sms/model"
|
||||
"spark-server/internal/module/user/model"
|
||||
)
|
||||
|
||||
func testService(t *testing.T) (*Service, *model.MemoryRepo, *model.MemoryCodes) {
|
||||
func testService(t *testing.T) (*Service, *model.MemoryRepo, smslogic.CodeStore) {
|
||||
t.Helper()
|
||||
ids, err := id.New(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := model.NewMemoryRepo()
|
||||
codes := model.NewMemoryCodes()
|
||||
return NewService(repo, codes, ids, newMemObjects()), repo, codes
|
||||
store := smsmodel.NewMemoryStore()
|
||||
smsSvc := smslogic.NewService(store, nil, nil, nil)
|
||||
return NewService(repo, smsSvc, ids, newMemObjects()), repo, store
|
||||
}
|
||||
|
||||
type memObjects struct {
|
||||
|
||||
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", "")
|
||||
ErrUnauthorized = NewError("40101", "unauthorized", "")
|
||||
ErrNotAuthenticated = NewError("40102", "not authenticated", "")
|
||||
ErrVerifyCodeInvalid = NewError("40103", "verification code invalid", "")
|
||||
ErrForbidden = NewError("40301", "forbidden", "")
|
||||
ErrNotFound = NewError("40401", "not found", "")
|
||||
ErrMediaExpired = NewError("40402", "media expired", "")
|
||||
|
||||
@ -23,6 +23,9 @@ func init() {
|
||||
"err.40102": {
|
||||
ZhCN: "未登录", En: "not authenticated", ZhTW: "未登入",
|
||||
},
|
||||
"err.40103": {
|
||||
ZhCN: "验证码无效", En: "Invalid verification code", ZhTW: "驗證碼無效",
|
||||
},
|
||||
"err.40301": {
|
||||
ZhCN: "无权限", En: "forbidden", ZhTW: "無權限",
|
||||
},
|
||||
|
||||
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) {
|
||||
cases := map[string]Lang{
|
||||
"": ZhCN,
|
||||
"": En,
|
||||
"zh": ZhCN,
|
||||
"zh-CN": ZhCN,
|
||||
"zh_CN": ZhCN,
|
||||
|
||||
@ -148,6 +148,35 @@ const (
|
||||
KeyMomentVisibleDaysInvalid = "validate.moment.visible_days_invalid"
|
||||
KeyMomentHideNotFriend = "validate.moment.hide_not_friend"
|
||||
|
||||
KeyFieldSquareText = "field.square_text"
|
||||
KeyFieldSquareVisibility = "field.square_visibility"
|
||||
KeyFieldSquareAudience = "field.square_audience"
|
||||
KeyFieldSquareComment = "field.square_comment"
|
||||
KeyFieldSquareReply = "field.square_reply"
|
||||
KeyFieldSquareNoticeCursor = "field.square_notice_cursor"
|
||||
KeySquareTextInvalid = "validate.square.text_invalid"
|
||||
KeySquareVisibilityInvalid = "validate.square.visibility_invalid"
|
||||
KeySquareAudienceInvalid = "validate.square.audience_invalid"
|
||||
KeySquareContentInvalid = "validate.square.content_invalid"
|
||||
KeySquareMediaConflict = "validate.square.media_conflict"
|
||||
KeySquareEmpty = "validate.square.empty"
|
||||
KeySquareCommentRequired = "validate.square.comment_required"
|
||||
KeySquareCommentInvalid = "validate.square.comment_invalid"
|
||||
KeySquareReplyInvalid = "validate.square.reply_invalid"
|
||||
|
||||
KeyFieldVerificationTarget = "field.verification_target"
|
||||
KeySMSLanguageRequired = "validate.sms.language_required"
|
||||
KeySMSTargetRequired = "validate.sms.target_required"
|
||||
KeySMSTargetInvalid = "validate.sms.target_invalid"
|
||||
KeyFieldVerificationPurpose = "field.verification_purpose"
|
||||
KeySMSPurposeRequired = "validate.sms.purpose_required"
|
||||
KeySMSPurposeInvalid = "validate.sms.purpose_invalid"
|
||||
KeySMSDeletePhoneUnsupported = "validate.sms.delete_phone_unsupported"
|
||||
KeySMSEmailSubjectLogin = "sms.email.subject_login"
|
||||
KeySMSEmailBodyLogin = "sms.email.body_login"
|
||||
KeySMSEmailSubjectDelete = "sms.email.subject_delete"
|
||||
KeySMSEmailBodyDelete = "sms.email.body_delete"
|
||||
|
||||
NotifyKickReplaced = "notify.kick.replaced"
|
||||
NotifyKickLogout = "notify.kick.logout"
|
||||
NotifyKickDeleted = "notify.kick.deleted"
|
||||
|
||||
@ -11,7 +11,7 @@ const (
|
||||
ZhCN Lang = "zh-CN"
|
||||
En Lang = "en"
|
||||
ZhTW Lang = "zh-TW"
|
||||
Default = ZhCN
|
||||
Default = En
|
||||
)
|
||||
|
||||
func (l Lang) String() string {
|
||||
@ -22,6 +22,7 @@ func (l Lang) String() string {
|
||||
}
|
||||
|
||||
// Parse maps Accept-Language, X-Language, or TCP AUTH language to ZhCN / En / ZhTW.
|
||||
// Unknown or empty input falls back to Default (en).
|
||||
func Parse(raw string) Lang {
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
tag, _, _ := strings.Cut(strings.TrimSpace(part), ";")
|
||||
@ -32,6 +33,21 @@ func Parse(raw string) Lang {
|
||||
return Default
|
||||
}
|
||||
|
||||
// ParseExplicit requires a recognized language tag; ok is false when raw is empty or unknown.
|
||||
func ParseExplicit(raw string) (Lang, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return Default, false
|
||||
}
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
tag, _, _ := strings.Cut(strings.TrimSpace(part), ";")
|
||||
if lang, ok := match(tag); ok {
|
||||
return lang, true
|
||||
}
|
||||
}
|
||||
return Default, false
|
||||
}
|
||||
|
||||
func match(tag string) (Lang, bool) {
|
||||
t := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(tag), "_", "-"))
|
||||
if t == "" {
|
||||
|
||||
@ -30,7 +30,7 @@ func TestOKAndFailEnvelope(t *testing.T) {
|
||||
if fail.Code != errs.ErrNotAuthenticated.Code || fail.Msg != "login first" || string(fail.Data) != "{}" {
|
||||
t.Fatalf("fail %+v", fail)
|
||||
}
|
||||
zh := FromError(errs.ErrUnauthorized)
|
||||
zh := FromErrorLang(i18n.ZhCN, errs.ErrUnauthorized)
|
||||
if zh.Msg != "未授权" {
|
||||
t.Fatalf("zh %q", zh.Msg)
|
||||
}
|
||||
|
||||
@ -87,7 +87,7 @@ func TestLangFromContext(t *testing.T) {
|
||||
if Language(ctx) != "en" {
|
||||
t.Fatalf("got %q", Language(ctx))
|
||||
}
|
||||
if Language(t.Context()) != "zh-CN" {
|
||||
if Language(t.Context()) != "en" {
|
||||
t.Fatalf("default %q", Language(t.Context()))
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,7 +34,7 @@ func engine() *validator.Validate {
|
||||
return v
|
||||
}
|
||||
|
||||
// Struct validates v in zh-CN. Prefer StructLang with the request language.
|
||||
// Struct validates v in the default language (en). Prefer StructLang with the request language.
|
||||
func Struct(val any) error {
|
||||
return StructLang(i18n.Default, val)
|
||||
}
|
||||
|
||||
@ -23,12 +23,12 @@ func TestStructCustomAndDefaultMessages(t *testing.T) {
|
||||
if app.Code != errs.ErrInvalidArgument.Code {
|
||||
t.Fatalf("code %s", app.Code)
|
||||
}
|
||||
if !strings.Contains(app.Msg, "请填写手机号") || !strings.Contains(app.Msg, "密码不能为空") || !strings.Contains(app.Msg, "设备ID不能为空") {
|
||||
if !strings.Contains(app.Msg, "Please enter your phone number") || !strings.Contains(app.Msg, "Password is required") || !strings.Contains(app.Msg, "Device ID is required") {
|
||||
t.Fatalf("msg %s", app.Msg)
|
||||
}
|
||||
err = Struct(sample{Phone: "1", Password: "123", DeviceID: 1})
|
||||
app, ok = errs.AsAppError(err)
|
||||
if !ok || !strings.Contains(app.Msg, "密码不能少于6") {
|
||||
if !ok || !strings.Contains(app.Msg, "at least 6") {
|
||||
t.Fatalf("min %v", err)
|
||||
}
|
||||
if err := Struct(sample{Phone: "1", Password: "123456", DeviceID: 1}); err != nil {
|
||||
|
||||
@ -76,7 +76,7 @@ func AuthRateLimit(l *ratelimit.Limiter) gin.HandlerFunc {
|
||||
|
||||
func authLimitPath(path string) bool {
|
||||
switch path {
|
||||
case "/v1/auth/login", "/v1/auth/email-codes", "/v1/auth/token/refresh":
|
||||
case "/v1/auth/login", "/v1/auth/token/refresh", "/v1/sms/codes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@ -87,7 +87,7 @@ func publicPath(path string) bool {
|
||||
switch path {
|
||||
case "/healthz", "/readyz", "/openapi.yaml", "/docs", "/docs/":
|
||||
return true
|
||||
case "/v1/meta/enums", "/v1/auth/login", "/v1/auth/email-codes", "/v1/auth/token/refresh":
|
||||
case "/v1/meta/enums", "/v1/auth/login", "/v1/auth/token/refresh", "/v1/sms/codes":
|
||||
return true
|
||||
default:
|
||||
// Admin routes use AdminAuth (Java popadmin JWT), not client Bearer.
|
||||
|
||||
@ -93,7 +93,7 @@ info:
|
||||
|
||||
### 调用顺序
|
||||
|
||||
1. (可选)`POST /v1/auth/email-codes` Body `{"email"}`。测试号 `test01@spark.com`~`test10@spark.com` 可跳过,登录任意 `verify_code`。
|
||||
1. (可选)`POST /v1/sms/codes` Header **`X-Language` 必填**(`zh-CN` / `en` / `zh-TW`),Body `{"purpose":1,"target":"邮箱或手机号"}`。服务端按格式识别邮箱/手机。测试号 `test01@spark.com`~`test10@spark.com` 可跳过发码。
|
||||
2. `POST /v1/auth/login` Body 拿 `access_token` / `refresh_token`。`device_id` 由客户端生成并持久化,不要每次换。
|
||||
3. 其它 HTTP:Header `Authorization: Bearer …`,可选 `X-Device-ID`、`X-Language`。
|
||||
4. 续期:`POST /v1/auth/token/refresh`,**Body** `{"refresh_token"}`,不要带 Bearer。
|
||||
@ -362,6 +362,7 @@ tags:
|
||||
- name: files
|
||||
- name: favorites
|
||||
- name: moments
|
||||
- name: square
|
||||
paths:
|
||||
/healthz:
|
||||
get:
|
||||
@ -503,24 +504,28 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EnumsEnvelope"
|
||||
/v1/auth/email-codes:
|
||||
/v1/sms/codes:
|
||||
post:
|
||||
tags: [auth]
|
||||
tags: [sms]
|
||||
security: []
|
||||
summary: 发送邮箱登录验证码
|
||||
summary: 发送验证码
|
||||
description: |
|
||||
验证码 5 分钟有效,同一邮箱 60 秒内只能发一次。
|
||||
测试账号 `test01@spark.com`~`test10@spark.com` 无需发码,登录时任意填写 `verify_code`。
|
||||
当前不发送真实邮件,验证码写入 Redis(无 Redis 时为进程内存)。
|
||||
operationId: sendEmailCode
|
||||
Body:`purpose` 1=登录 2=注销;`target` 为邮箱或大陆手机号(服务端识别,无需 channel)。
|
||||
**Header `X-Language` 必填**(不接受仅 Accept-Language),用于邮件正文语言;未识别时默认 `en`。
|
||||
登录发码无需 Bearer;注销发码需 Bearer,且 `target` 须为当前绑定邮箱。
|
||||
邮箱经 SMTP(`noreply@sparkmail.wyrs365.com`);手机走阿里云短信。
|
||||
operationId: sendVerificationCode
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/X-Language"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SendEmailCodeRequest"
|
||||
$ref: "#/components/schemas/SendVerificationCodeRequest"
|
||||
example:
|
||||
email: ada@example.com
|
||||
purpose: 1
|
||||
target: ada@example.com
|
||||
responses:
|
||||
"200":
|
||||
description: 统一 envelope
|
||||
@ -528,13 +533,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
examples:
|
||||
ok:
|
||||
value:
|
||||
code: "0"
|
||||
msg: ok
|
||||
data:
|
||||
expires_in: 300
|
||||
/v1/auth/login:
|
||||
post:
|
||||
tags: [auth]
|
||||
@ -693,9 +691,16 @@ paths:
|
||||
summary: 注销当前账号
|
||||
description: |
|
||||
吊销全部设备的 JWT 并踢掉 TCP。将 `users` 行迁入 `user_delete` 后删除原行(邮箱可重新注册)。
|
||||
非测试账号需先 `POST /v1/sms/codes`(purpose=2, channel=1)向绑定邮箱发码,再在本接口提交 `verify_code`。
|
||||
同时清除好友关系、朋友圈、收藏、群成员(群主把群转给一名管理员,没有管理员则转给成员表第一位后退出;仅剩自己则解散)、会话成员、收件箱 Redis 键与 mailbox。
|
||||
对方仍可见历史消息(不删 `messages`)。
|
||||
operationId: deleteMe
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DeleteAccountRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 统一 envelope
|
||||
@ -1121,6 +1126,7 @@ paths:
|
||||
`group_avatar` 写入 `perm/group/{群号}/{name}.{ext}`(需 `group_id`,群主/管理员,匿名 GET);
|
||||
聊天按类型:`chat/image|voice|video|gif|file/{yyyyMMdd}/{name}.{ext}`(桶生命周期按前缀过期)。
|
||||
朋友圈:`moment_image` / `moment_video` / `moment_cover` 写入 `perm/moment/{image|video|cover}/{yyyyMMdd}/{name}.{ext}`(不过期,删帖/换封面时删对象)。
|
||||
广场:`square_image` / `square_video` 写入 `perm/square/{image|video}/{yyyyMMdd}/{name}.{ext}`(不过期,删帖时删对象)。
|
||||
`{name}` 为服务端 16 位 base62 随机串,不占用 Snowflake。
|
||||
发图片:客户端压成 JPEG/PNG 后 `POST /v1/files/batch` 直传缩略图、压缩大图、原图(未勾选原图只传前两项)。
|
||||
服务端不转码。iOS HEIC、Android WebP 须客户端先转 JPEG/PNG,两端才能互看。
|
||||
@ -1602,6 +1608,209 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
/v1/square/posts:
|
||||
get:
|
||||
tags: [square]
|
||||
summary: 全站广场 Feed
|
||||
description: |
|
||||
`cursor` 为上一页最后一条 `post_id`,0 表示最新。仅 `visibility` 公开与不给谁看、且审核通过的帖;
|
||||
读时按拉黑、黑名单过滤。评论随帖详情返回,无单独评论列表接口。
|
||||
operationId: listSquareFeed
|
||||
parameters:
|
||||
- in: query
|
||||
name: cursor
|
||||
schema: { type: integer, format: int64, default: 0 }
|
||||
- in: query
|
||||
name: limit
|
||||
schema: { type: integer, default: 20, maximum: 50 }
|
||||
responses:
|
||||
"200":
|
||||
description: 统一 envelope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
post:
|
||||
tags: [square]
|
||||
summary: 发广场帖
|
||||
description: |
|
||||
内容与朋友圈相同。媒体先 `POST /v1/files`(`square_image` / `square_video`)再填 key。
|
||||
`visibility`:0 全站公开 1 仅自己 2 白名单 3 黑名单。2/3 需 `audience_uids`(双向好友,最多 200)。
|
||||
operationId: publishSquarePost
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PublishMomentRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 统一 envelope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
/v1/square/posts/{id}:
|
||||
get:
|
||||
tags: [square]
|
||||
summary: 单条广场帖
|
||||
operationId: getSquarePost
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SquarePostID"
|
||||
responses:
|
||||
"200":
|
||||
description: 统一 envelope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
delete:
|
||||
tags: [square]
|
||||
summary: 删除自己的广场帖
|
||||
operationId: deleteSquarePost
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SquarePostID"
|
||||
responses:
|
||||
"200":
|
||||
description: 统一 envelope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
/v1/square/posts/{id}/like:
|
||||
put:
|
||||
tags: [square]
|
||||
summary: 点赞
|
||||
operationId: likeSquarePost
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SquarePostID"
|
||||
responses:
|
||||
"200":
|
||||
description: 统一 envelope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
delete:
|
||||
tags: [square]
|
||||
summary: 取消赞
|
||||
operationId: unlikeSquarePost
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SquarePostID"
|
||||
responses:
|
||||
"200":
|
||||
description: 统一 envelope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
/v1/square/posts/{id}/comments:
|
||||
post:
|
||||
tags: [square]
|
||||
summary: 评论或回复
|
||||
operationId: commentSquarePost
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SquarePostID"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MomentCommentRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 统一 envelope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
/v1/square/posts/{id}/comments/{cid}:
|
||||
delete:
|
||||
tags: [square]
|
||||
summary: 删除评论
|
||||
operationId: deleteSquareComment
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SquarePostID"
|
||||
- name: cid
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer, format: int64 }
|
||||
responses:
|
||||
"200":
|
||||
description: 统一 envelope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
/v1/square/users/{uid}/posts:
|
||||
get:
|
||||
tags: [square]
|
||||
summary: 某用户广场帖
|
||||
description: 按当前浏览者与帖可见性、拉黑过滤;看自己可见私密与白名单帖。
|
||||
operationId: listSquareUserPosts
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/FriendUID"
|
||||
- in: query
|
||||
name: cursor
|
||||
schema: { type: integer, format: int64, default: 0 }
|
||||
- in: query
|
||||
name: limit
|
||||
schema: { type: integer, default: 20, maximum: 50 }
|
||||
responses:
|
||||
"200":
|
||||
description: 统一 envelope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
/v1/square/notices:
|
||||
get:
|
||||
tags: [square]
|
||||
summary: 赞评通知列表
|
||||
operationId: listSquareNotices
|
||||
parameters:
|
||||
- in: query
|
||||
name: cursor
|
||||
schema: { type: integer, format: int64, default: 0 }
|
||||
- in: query
|
||||
name: limit
|
||||
schema: { type: integer, default: 20, maximum: 50 }
|
||||
responses:
|
||||
"200":
|
||||
description: 统一 envelope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
/v1/square/notices/unread:
|
||||
get:
|
||||
tags: [square]
|
||||
summary: 广场互动未读角标
|
||||
operationId: squareNoticeUnread
|
||||
responses:
|
||||
"200":
|
||||
description: 统一 envelope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
/v1/square/notices/read:
|
||||
post:
|
||||
tags: [square]
|
||||
summary: 通知标已读
|
||||
operationId: readSquareNotices
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MomentNoticeReadRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 统一 envelope
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Envelope"
|
||||
/v1/conversations:
|
||||
get:
|
||||
tags: [conversations]
|
||||
@ -1830,7 +2039,7 @@ components:
|
||||
description: 缺省 zh-CN
|
||||
UploadPurpose:
|
||||
type: string
|
||||
enum: [avatar, group_avatar, chat_image, chat_voice, chat_video, chat_gif, chat_file, moment_image, moment_video, moment_cover]
|
||||
enum: [avatar, group_avatar, chat_image, chat_voice, chat_video, chat_gif, chat_file, moment_image, moment_video, moment_cover, square_image, square_video]
|
||||
SystemKind:
|
||||
type: string
|
||||
enum: [friend.became, group.created, group.invite, group.leave, group.kick, group.dissolve, group.transfer]
|
||||
@ -2076,13 +2285,25 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/EnumItem"
|
||||
SendEmailCodeRequest:
|
||||
SendVerificationCodeRequest:
|
||||
type: object
|
||||
required: [email]
|
||||
required: [purpose, target]
|
||||
properties:
|
||||
email:
|
||||
purpose:
|
||||
$ref: "#/components/schemas/VerificationPurpose"
|
||||
target:
|
||||
type: string
|
||||
format: email
|
||||
description: 邮箱或大陆 11 位手机号(可带 +86)
|
||||
DeleteAccountRequest:
|
||||
type: object
|
||||
properties:
|
||||
verify_code:
|
||||
type: string
|
||||
description: 注销验证码(purpose=account_delete)。测试账号可省略。
|
||||
VerificationPurpose:
|
||||
type: integer
|
||||
enum: [1, 2]
|
||||
description: 1=登录 2=注销账号
|
||||
LoginRequest:
|
||||
type: object
|
||||
required: [email, verify_code, device_id]
|
||||
@ -2541,6 +2762,14 @@ components:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 朋友圈 Snowflake post_id。
|
||||
SquarePostID:
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 广场 Snowflake post_id。
|
||||
GroupID:
|
||||
in: path
|
||||
name: id
|
||||
|
||||
@ -20,8 +20,7 @@ import (
|
||||
msgentity "spark-server/internal/module/message/entity"
|
||||
msgmodel "spark-server/internal/module/message/model"
|
||||
uploadlogic "spark-server/internal/module/upload/logic"
|
||||
userlogic "spark-server/internal/module/user/logic"
|
||||
usermodel "spark-server/internal/module/user/model"
|
||||
"spark-server/internal/module/user/logictest"
|
||||
"spark-server/internal/pkg/errs"
|
||||
)
|
||||
|
||||
@ -45,7 +44,7 @@ func testFileEngine(t *testing.T) (*ginEngineBundle, *id.Generator, *msgmodel.Me
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil)
|
||||
users, _ := logictest.UsersAndSMS(ids, nil)
|
||||
msgs := msgmodel.NewMemoryRepo()
|
||||
convs := convmodel.NewMemoryP2P()
|
||||
files := uploadlogic.NewService(&stubPresign{}, uploadlogic.Options{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user