feat: 1、square 2、smtp

This commit is contained in:
zhr 2026-09-22 17:07:26 +08:00
parent 94dd2d819c
commit edec7d82e9
116 changed files with 6003 additions and 467 deletions

View 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.

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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。

View File

@ -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
View 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
```

View File

@ -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: ""

View File

@ -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
View 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
View 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
View 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`

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
View 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,
}),
}
}

View 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)

View 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
}

View File

@ -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

View File

@ -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
}

View File

@ -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 }

View File

@ -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
}

View File

@ -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()),

View File

@ -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()

View File

@ -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"`
}

View File

@ -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),
}
}

View 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)),
}
}

View File

@ -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),

View File

@ -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

View 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
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}

View 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")
}
}

View 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
}

View 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
}

View 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)
}
}

View 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)
}
}

View 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
}
}

View 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)
}

View 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 ""
}
}

View 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)
}
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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}
}

View 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)
}

View 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
}

View 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
}

View 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)
}
}

View 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
}

View 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
}

View 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
}

View 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}
}

View 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
}

View 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
}

View 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
}

View 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")
}
}

View 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)
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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(),
}
}

View 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)
}

View 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
}

View 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
}

View 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)
}

View File

@ -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},

View File

@ -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"`

View File

@ -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 {

View File

@ -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)

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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) {

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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 {

View 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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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", "")

View File

@ -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: "無權限",
},

View 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: "中國電信",
},
})
}

View 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: "广场影片"},
})
}

View File

@ -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,

View File

@ -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"

View File

@ -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 == "" {

View File

@ -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)
}

View File

@ -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()))
}
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -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.

View File

@ -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

View File

@ -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