Compare commits

..
65 Commits
Author SHA1 Message Date
richjohnrambo f56b84bb10 fix: display finance records and ledgers in Beijing time
Deploy olons-server production / deploy (push) Successful in 34s
2026-10-23 00:13:07 +08:00
richjohnrambo 8ce67648b1 feat: add global system notices and Beijing order time display
Deploy olons-server production / deploy (push) Successful in 3m12s
2026-10-22 21:56:07 +08:00
richjohnrambo 9bb479b506 feat: use floating customer service widget in pro
Deploy olons-server production / deploy (push) Successful in 3m1s
2026-10-21 13:15:15 +08:00
richjohnrambo 79c502cb20 fix: exclude test accounts from leaderboard
Deploy olons-server production / deploy (push) Successful in 4m29s
2026-10-20 23:35:17 +08:00
richjohnrambo 47b545d050 feat: directly add customer service friend
Deploy olons-server production / deploy (push) Successful in 3m26s
2026-10-20 22:24:56 +08:00
richjohnrambo 78165b7a15 feat: use login popup announcement on home
Deploy olons-server production / deploy (push) Successful in 4m57s
2026-10-20 22:13:51 +08:00
Your Name 6954d375b5 fix: select support identity by IM tenant
Deploy olons-server production / deploy (push) Successful in 5m4s
2026-10-20 21:42:43 +08:00
Your Name 975a2a2ed2 fix: preserve pending support state on private profiles
Deploy olons-server production / deploy (push) Successful in 2m42s
2026-10-20 21:31:44 +08:00
Your Name 5f85472757 fix: handle private pending support relationships
Deploy olons-server production / deploy (push) Successful in 3m23s
2026-10-20 21:25:19 +08:00
richjohnrambo 92b5656f3f fix: reprovision IM member identities across tenants
Deploy olons-server production / deploy (push) Successful in 2m40s
2026-10-20 20:55:10 +08:00
Your Name cdebd0a404 fix(ci): deploy olons server from managed compose env
Deploy olons-server production / deploy (push) Successful in 32s
2026-10-20 17:49:03 +08:00
richjohnrambo ba87d70478 fix: show customer chat before connection completes
Deploy olons-server production / deploy (push) Successful in 4m30s
2026-10-19 21:32:56 +08:00
Your Name db1367b8fe fix: render inline support images
Deploy olons-server production / deploy (push) Successful in 4m1s
2026-10-19 18:06:00 +08:00
Your Name db18514e6d fix: make support IM delivery recoverable
Deploy olons-server production / deploy (push) Successful in 3m38s
2026-10-19 17:23:34 +08:00
Your Name c39ec78e89 docs: design support IM reliability fix 2026-10-19 16:50:29 +08:00
richjohnrambo e6c421ac3e fix: inject production IM API key
Deploy olons-server production / deploy (push) Successful in 37s
2026-10-19 13:33:10 +08:00
richjohnrambo 74785f948b fix: use personal login for default IM tenant
Deploy olons-server production / deploy (push) Successful in 2m48s
2026-10-19 13:25:32 +08:00
richjohnrambo f2f4720340 chore: expose support integration errors during deploy
Deploy olons-server production / deploy (push) Successful in 1m9s
2026-10-19 13:22:44 +08:00
richjohnrambo dffd47294e fix: preserve production compose variables
Deploy olons-server production / deploy (push) Successful in 1m9s
2026-10-19 13:20:27 +08:00
richjohnrambo 950890a734 fix: override production IM endpoints
Deploy olons-server production / deploy (push) Failing after 35s
2026-10-19 13:17:59 +08:00
richjohnrambo 179a9bdd16 fix: bind Olons support to production IM
Deploy olons-server production / deploy (push) Successful in 52s
2026-10-19 13:16:10 +08:00
richjohnrambo aa0e17f1f1 fix: inject IM internal secret in production
Deploy olons-server production / deploy (push) Successful in 33s
2026-10-19 13:13:08 +08:00
richjohnrambo 47bb4fe2ed Merge source dev into pro
Deploy olons-server production / deploy (push) Successful in 1m27s
2026-10-19 11:30:39 +08:00
richjohnrambo 355d59813b fix: use default IM tenant 2026-10-19 11:28:15 +08:00
dev 04a3a0f538 Merge source dev into pro
Deploy olons-server production / deploy (push) Successful in 1m50s
2026-10-19 11:14:26 +08:00
richjohnrambo 1c11c0fa82 fix: restore payment account details 2026-10-19 11:14:09 +08:00
dev 5e0abedbcc Merge source dev into pro
Deploy olons-server production / deploy (push) Successful in 1m45s
2026-10-19 09:34:44 +08:00
richjohnrambo 21371061ed feat: improve home notice ticker 2026-10-19 09:34:26 +08:00
Your Name 319768bd82 test: align deployment contract with production
Deploy olons-server production / deploy (push) Successful in 28s
2026-10-19 02:55:32 +08:00
dev d8facff163 Merge source dev into pro
Deploy olons-server production / deploy (push) Successful in 2m29s
2026-10-19 01:03:52 +08:00
richjohnrambo 2e47dd3935 fix: remove tenant dependency from member login 2026-10-19 01:03:31 +08:00
richjohnrambo 715740fc30 Merge remote-tracking branch 'refs/remotes/origin/dev' into HEAD
Deploy olons-server production / deploy (push) Successful in 3m10s
2026-10-19 00:31:28 +08:00
richjohnrambo 804318e5f5 fix: load mall claimed counts from database 2026-10-19 00:02:35 +08:00
richjohnrambo 8b773a1a1f feat: show mall claimed counts 2026-10-18 22:52:20 +08:00
richjohnrambo 86efdf4525 feat mobile handmade mall grid 2026-10-18 21:34:27 +08:00
richjohnrambo 5a0fe67027 fix mobile task rules and feature availability 2026-10-18 20:53:33 +08:00
Your Name f9497118bb ci: deploy pro with docker compose
Deploy olons-server production / deploy (push) Successful in 3m29s
2026-10-18 20:11:07 +08:00
richjohnrambo b79b781f15 feat: add object storage and points leaderboard 2026-10-18 18:11:34 +08:00
richjohnrambo bce5edfaf5 feat: connect portal content and banner carousel 2026-10-16 18:59:16 +08:00
richjohnrambo a86db10342 fix: edit existing payment account in binding form 2026-10-16 14:58:45 +08:00
richjohnrambo 70cd0e592a fix: isolate customer service chats and reload bank details 2026-10-16 14:01:31 +08:00
richjohnrambo 443622cd3c fix: preserve legacy payment account display 2026-10-16 10:14:41 +08:00
richjohnrambo ecf1a89313 feat: show own withdrawal account details and enable avatar upload 2026-10-16 00:19:11 +08:00
richjohnrambo 07c654b6d4 fix: add close button to withdrawal confirmation modal 2026-10-15 23:08:01 +08:00
richjohnrambo 66121f48e9 fix: allow optional digital RMB code and inline payment password setup 2026-10-15 23:03:59 +08:00
richjohnrambo 6e223e537b feat: open support after recharge submission 2026-10-15 20:54:10 +08:00
richjohnrambo eb96b7ac1f feat: use six-digit numeric member invite codes 2026-10-15 18:41:35 +08:00
richjohnrambo b6a0f819b8 feat: require invitation code for registration 2026-10-15 17:31:42 +08:00
richjohnrambo 05d6ec26ad fix: require balance for automatic task claims 2026-10-15 16:40:39 +08:00
richjohnrambo acf50b9b7c feat: improve member payment and order flows 2026-10-15 15:03:39 +08:00
richjohnrambo 06eaf748ed fix: recover stale IM member credentials 2026-10-14 18:18:18 +08:00
richjohnrambo a6d7c8dda8 fix: inject IM credential into deployment 2026-10-14 18:09:55 +08:00
richjohnrambo 37913c37d1 fix: inject IM credential during deployment 2026-10-14 18:08:47 +08:00
richjohnrambo 48b3564ac9 feat: integrate customer service chat 2026-10-14 16:37:12 +08:00
richjohnrambo f3f59fe7c3 feat add customer service IM client 2026-10-14 10:08:57 +08:00
richjohnrambo dedacfcd9a feat: 完善自动与手动派单流程 2026-10-13 22:13:47 +08:00
richjohnrambo 7eedbde4ab fix: parse configured dispatch delay strings 2026-10-13 17:05:53 +08:00
richjohnrambo acbfaf97a6 fix: hide countdown for manual dispatch 2026-10-13 16:55:53 +08:00
richjohnrambo b3bf41af8b fix: preserve order dispatch mode 2026-10-13 15:45:55 +08:00
richjohnrambo 45ef10b2af feat: add scheduled automatic task matching 2026-10-13 15:21:08 +08:00
Your Name 2a3c411c9b fix: serve integrated frontend from root 2026-10-13 15:06:53 +08:00
Your Name 76421fbc2d docs: fix actions plan formatting 2026-10-13 14:36:41 +08:00
Your Name 5e01e4344b docs: plan manual actions entry 2026-10-13 14:34:39 +08:00
Your Name 4a70b85353 docs: record manual actions branch choice 2026-10-13 14:29:40 +08:00
richjohnrambo b2ebea4c80 feat: 完善移动端会员业务流程 2026-10-13 14:19:55 +08:00
112 changed files with 8230 additions and 1366 deletions
+66 -22
View File
@@ -1,37 +1,81 @@
name: Deploy olons-server (k3s test cluster) name: Deploy olons-server production
on: on:
push:
branches:
- pro
workflow_dispatch: workflow_dispatch:
inputs:
image_tag: concurrency:
description: '镜像tag,默认为 test(与集群 kustomization 里的 newTag 一致)' group: production-olons-server
required: false cancel-in-progress: false
permissions:
contents: read
env:
REGISTRY: git.olns.shop
IMAGE: git.olns.shop/pro/olons_server
SERVICE: olons-server
DEPLOY_HOST: 145.223.69.9
DEPLOY_USER: olns-deploy
jobs: jobs:
deploy: deploy:
runs-on: deploy-host runs-on: deploy-host
timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: 确定镜像tag - name: Log in to Gitea Container Registry
id: vars env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
TAG="${{ github.event.inputs.image_tag }}" set -euo pipefail
[ -z "$TAG" ] && TAG=test printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u admin --password-stdin
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: 登录镜像仓库 - name: Build and push image
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.118im.com -u admin --password-stdin env:
GITHUB_SHA: ${{ github.sha }}
- name: 构建并推送镜像
run: | run: |
docker build -t git.118im.com/im_team/olons-server:${{ steps.vars.outputs.tag }} . set -euo pipefail
docker push git.118im.com/im_team/olons-server:${{ steps.vars.outputs.tag }} SHORT_SHA="$(printf '%.12s' "$GITHUB_SHA")"
docker build --build-arg VITE_CUSTOMER_SERVICE_PROVIDER=third_party -t "${IMAGE}:pro" -t "${IMAGE}:${SHORT_SHA}" .
docker push "$IMAGE:pro"
docker push "$IMAGE:$SHORT_SHA"
- name: 部署到k3s测试集群 - name: Deploy Compose service
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: | run: |
mkdir -p ~/.kube set -euo pipefail
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config install -d -m 700 "$HOME/.ssh"
chmod 600 ~/.kube/config KEY_FILE="$(mktemp "$HOME/.ssh/olns-deploy.XXXXXX")"
kubectl rollout restart deployment/olons-server -n olons-server-system cleanup() {
kubectl rollout status deployment/olons-server -n olons-server-system --timeout=120s shred -u "$KEY_FILE" 2>/dev/null || true
}
trap cleanup EXIT
printf '%s\n' "$DEPLOY_SSH_KEY" > "$KEY_FILE"
chmod 600 "$KEY_FILE"
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
chmod 600 "$HOME/.ssh/known_hosts"
SSH_ARGS=(-i "$KEY_FILE" -o BatchMode=yes -o StrictHostKeyChecking=yes)
printf '%s' "$REGISTRY_TOKEN" | ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
"docker login git.olns.shop -u admin --password-stdin"
ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" '
set -euo pipefail
cd /opt/olns
docker compose pull olons-server
docker compose up -d --no-deps --wait olons-server
docker compose exec -T olons-server sh -c '"'"'
test -n "$OLONS_IM_INTERNAL_SECRET"
test "$OLONS_IM_TENANT_CODE" = "default"
test "$OLONS_SERVER_DEFAULT_TENANT_ID" = "0"
test "$OLONS_IM_BASE_URL" = "http://im-server:6060"
test "$OLONS_IM_HTTP_API_URL" = "http://im-server:6060/v1/im"
test "$OLONS_IM_WS_URL" = "ws://im-server:6060/v0/channels"
test "$OLONS_IM_FILE_URL" = "http://im-server:6060/v0/file"
'"'"'
'
+2
View File
@@ -26,6 +26,8 @@ go.work.sum
# env file # env file
.env .env
.idea/ .idea/
.DS_Store
storage/uploads/
# ---> Node # ---> Node
# Logs # Logs
+8 -6
View File
@@ -6,7 +6,9 @@ RUN corepack enable
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./ COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
COPY web/ ./ COPY web/ ./
ENV VITE_PUBLIC_PATH=/admin/ ARG VITE_CUSTOMER_SERVICE_PROVIDER=
ENV VITE_PUBLIC_PATH=/
ENV VITE_CUSTOMER_SERVICE_PROVIDER=${VITE_CUSTOMER_SERVICE_PROVIDER}
RUN pnpm run build RUN pnpm run build
# ---- Stage 2: build the Go backend ---- # ---- Stage 2: build the Go backend ----
@@ -17,16 +19,16 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
COPY --from=web-builder /web/dist ./web/dist COPY --from=web-builder /web/dist ./web/dist
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/im_workbench ./cmd/workbench RUN CGO_ENABLED=0 GOOS=linux go build -o /out/olons_server ./cmd/olons_server
FROM alpine:3.22 FROM alpine:3.22
WORKDIR /app WORKDIR /app
RUN apk add --no-cache tzdata RUN apk add --no-cache tzdata
ENV WORKBENCH_CONFIG=/app/configs/workbench.test.json ENV OLONS_SERVER_CONFIG=/app/configs/olons_server.test.json
COPY --from=builder /out/im_workbench /app/im_workbench COPY --from=builder /out/olons_server /app/olons_server
COPY configs /app/configs COPY configs /app/configs
COPY --from=web-builder /web/dist /app/web/dist COPY --from=web-builder /web/dist /app/web/dist
ENV WORKBENCH_FRONTEND_DIR=/app/web/dist ENV OLONS_SERVER_FRONTEND_DIR=/app/web/dist
EXPOSE 6061 EXPOSE 6061
ENTRYPOINT ["/app/im_workbench"] ENTRYPOINT ["/app/olons_server"]
+2 -2
View File
@@ -1,13 +1,13 @@
.PHONY: build test run web-install web-build .PHONY: build test run web-install web-build
build: build:
go build -o bin/im_workbench ./cmd/workbench go build -o bin/olons_server ./cmd/olons_server
test: test:
go test ./... go test ./...
run: run:
WORKBENCH_CONFIG=configs/workbench.local.json go run ./cmd/workbench OLONS_SERVER_CONFIG=configs/olons_server.local.json go run ./cmd/olons_server
web-install: web-install:
cd web && pnpm install --frozen-lockfile cd web && pnpm install --frozen-lockfile
+13 -12
View File
@@ -1,8 +1,8 @@
# Workbench # Olons Server
移动端任务与账户平台后端。项目是独立应用,业务数据以 MySQL `olons_admin` 库中的 `hg_*` 表为唯一事实源。 移动端任务与账户平台后端。项目是独立应用,业务数据以 MySQL `olons_admin` 库中的 `hg_*` 表为唯一事实源。
项目现在采用前后端一体目录结构:`web/` 是参考 `im_manager` 组织的 Vue 3 + Vite 管理端,Go 服务在生产环境通过 `/admin/` 提供构建后的静态资源,`/v1/` 继续提供业务 API。 项目现在采用前后端一体目录结构:`web/` 是 Vue 3 + Vite 移动端,Go 服务在生产环境通过网站根路径 `/` 提供构建后的静态资源,`/v1/` 继续提供业务 API。
## 当前接口 ## 当前接口
@@ -28,6 +28,8 @@
- `GET /v1/manual-products/{product_id}` - `GET /v1/manual-products/{product_id}`
- `GET /v1/payment-accounts` - `GET /v1/payment-accounts`
- `POST /v1/payment-accounts` - `POST /v1/payment-accounts`
- `PUT /v1/payment-accounts`
- `GET /v1/payment-accounts/details`
- `POST /v1/withdrawal-orders/quote` - `POST /v1/withdrawal-orders/quote`
- `POST /v1/withdrawal-orders` - `POST /v1/withdrawal-orders`
- `GET /v1/withdrawal-orders` - `GET /v1/withdrawal-orders`
@@ -42,17 +44,16 @@
核心环境变量: 核心环境变量:
```bash ```bash
WORKBENCH_CONFIG=configs/workbench.local.json OLONS_SERVER_CONFIG=configs/olons_server.local.json
WORKBENCH_MYSQL_DSN='user:password@tcp(host:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local' OLONS_SERVER_MYSQL_DSN='user:password@tcp(host:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local'
WORKBENCH_REDIS_ADDR='127.0.0.1:6379' OLONS_SERVER_REDIS_ADDR='127.0.0.1:6379'
WORKBENCH_DATA_ENCRYPTION_KEY='<32 字节随机密钥的 Base64>' OLONS_SERVER_FINANCE_MASTER_KEY='<至少 32 个字符的固定资金安全密钥>'
WORKBENCH_LOOKUP_HMAC_KEY='<至少 32 字节随机密钥的 Base64>' OLONS_SERVER_API_KEYS='<API Key,多个以逗号分隔>'
WORKBENCH_API_KEYS='<API Key,多个以逗号分隔>'
``` ```
`WORKBENCH_DATA_ENCRYPTION_KEY` 和 `WORKBENCH_LOOKUP_HMAC_KEY` 用于保护收款账户姓名、账号、银行卡预留手机号及取出快照等支付敏感信息。两个密钥必须分开生成、通过部署 Secret 注入,并与已有数据的加密配置保持一致;会员登录手机号直接保存在 `hg_app_member.mobile`。 `finance_master_key` 已在 JSON 配置中提供固定值,也可由 `OLONS_SERVER_FINANCE_MASTER_KEY` 覆盖。服务会从这个主密钥派生数据加密键和检索键,用于保护收款账户姓名、账号、银行卡预留手机号及取出快照等支付敏感信息。密钥必须与已有数据保持一致;会员登录手机号直接保存在 `hg_app_member.mobile`。
前端静态目录可通过 `WORKBENCH_FRONTEND_DIR` 覆盖,默认值为 `web/dist`。本地开发可分别启动前后端: 前端静态目录可通过 `OLONS_SERVER_FRONTEND_DIR` 覆盖,默认值为 `web/dist`。本地开发可分别启动前后端:
```bash ```bash
make web-install make web-install
@@ -60,7 +61,7 @@ make web-build
make run make run
``` ```
生产镜像使用 Docker 多阶段构建自动安装前端依赖、构建 `web/`,并将产物部署到 `/admin/`。 生产镜像使用 Docker 多阶段构建自动安装前端依赖、构建 `web/`,并将 C 端产物部署到网站根路径 `/`。
## 请求头 ## 请求头
@@ -70,7 +71,7 @@ X-Request-ID: <request id>
X-Device-ID: <optional device id> X-Device-ID: <optional device id>
``` ```
默认租户通过 `WORKBENCH_DEFAULT_TENANT_ID` 配置,现有 `olons_admin` 数据使用 `tenant_id=0`。服务不接受客户端传入租户身份。 服务采用单租户模式,登录仅按手机号查找账号,不读取或接受租户环境变量。现有数据库中的 `tenant_id` 字段仅作为兼容字段保留,新注册数据固定写入 `0`。
## 验证 ## 验证
+4 -4
View File
@@ -16,7 +16,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"git.118im.com/im_team/im_workbench/internal/config" "git.118im.com/im_team/olons_server/internal/config"
_ "github.com/go-sql-driver/mysql" _ "github.com/go-sql-driver/mysql"
) )
@@ -59,9 +59,9 @@ func main() {
log.Fatal("mobile_cipher is missing before migration") log.Fatal("mobile_cipher is missing before migration")
} }
aeads := newAEADs(append(splitKeys(os.Getenv("WORKBENCH_MEMBER_MIGRATION_DATA_KEYS")), cfg.Auth.DataEncryptionKey)) aeads := newAEADs(append(splitKeys(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_DATA_KEYS")), cfg.Auth.DataEncryptionKey))
lookupKeys := decodeKeys(append(splitKeys(os.Getenv("WORKBENCH_MEMBER_MIGRATION_LOOKUP_KEYS")), cfg.Auth.LookupHMACKey)) lookupKeys := decodeKeys(append(splitKeys(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_LOOKUP_KEYS")), cfg.Auth.LookupHMACKey))
overrides, err := parseOverrides(os.Getenv("WORKBENCH_MEMBER_MIGRATION_OVERRIDES")) overrides, err := parseOverrides(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_OVERRIDES"))
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -10,8 +10,8 @@ import (
"syscall" "syscall"
"time" "time"
"git.118im.com/im_team/im_workbench/internal/app" "git.118im.com/im_team/olons_server/internal/app"
"git.118im.com/im_team/im_workbench/internal/config" "git.118im.com/im_team/olons_server/internal/config"
) )
func main() { func main() {
@@ -34,7 +34,7 @@ func main() {
errCh := make(chan error, 1) errCh := make(chan error, 1)
go func() { go func() {
log.Printf("workbench listening on %s", cfg.Listen) log.Printf("olons_server listening on %s", cfg.Listen)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err errCh <- err
} }
@@ -10,13 +10,15 @@
"addr": "127.0.0.1:6379", "addr": "127.0.0.1:6379",
"db": 0 "db": 0
}, },
"upload": {
"dir": "storage/uploads",
"public_path": "/uploads"
},
"auth": { "auth": {
"api_keys": [], "api_keys": [],
"session_key_pattern": "workbench:session:token:%s", "session_key_pattern": "olons_server:session:token:%s",
"session_ttl_seconds": 1209600, "session_ttl_seconds": 1209600,
"default_tenant_id": 0, "finance_master_key": "olons-finance-master-key-v1-20261013",
"data_encryption_key": "",
"lookup_hmac_key": "",
"allow_dev_header_auth": true, "allow_dev_header_auth": true,
"required_api_key": false "required_api_key": false
} }
@@ -10,13 +10,15 @@
"addr": "", "addr": "",
"db": 0 "db": 0
}, },
"upload": {
"dir": "storage/uploads",
"public_path": "/uploads"
},
"auth": { "auth": {
"api_keys": [], "api_keys": [],
"session_key_pattern": "workbench:session:token:%s", "session_key_pattern": "olons_server:session:token:%s",
"session_ttl_seconds": 1209600, "session_ttl_seconds": 1209600,
"default_tenant_id": 0, "finance_master_key": "olons-finance-master-key-v1-20261013",
"data_encryption_key": "",
"lookup_hmac_key": "",
"required_api_key": true "required_api_key": true
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 718 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 KiB

@@ -0,0 +1,90 @@
# Olons Server Manual Actions Entry Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让 Gitea 在 `olons_server` 仓库的 Actions 页面识别 `dev` 分支现有的手动部署工作流。
**Architecture:** 不复制或修改工作流文件,不触发部署;仅将仓库默认分支从初始化用的 `main` 改为实际开发与部署分支 `dev`。通过 Gitea API 在变更前后验证默认分支、工作流识别结果和 Actions 运行数量。
**Tech Stack:** Gitea 1.27 API、Gitea Actions、Git
---
### Task 1: 验证变更前状态
**Files:**
- Verify: `.gitea/workflows/deploy.yml`
- [ ] **Step 1: 确认远端 `dev` 包含手动部署工作流**
Run:
```bash
git show origin/dev:.gitea/workflows/deploy.yml | grep -F 'workflow_dispatch:'
```
Expected: 输出 `workflow_dispatch:`。
- [ ] **Step 2: 运行远端契约断言并确认其按预期失败**
使用现有 Gitea 登录态读取仓库与 Actions API,断言:
```python
assert repository["default_branch"] == "dev"
assert any(item["path"] == ".gitea/workflows/deploy.yml" and item["state"] == "active" for item in workflows["workflows"])
```
Expected: FAIL;变更前默认分支为 `main`,识别到的工作流数量为 0。
### Task 2: 修改默认分支
**Files:**
- Modify: Gitea repository setting `im_team/olons_server.default_branch`
- [ ] **Step 1: 通过 Gitea API 将默认分支改为 `dev`**
Run:
```bash
credential_data=$(printf 'protocol=https\nhost=git.118im.com\n\n' | git credential fill)
gitea_user=$(printf '%s\n' "$credential_data" | sed -n 's/^username=//p')
gitea_password=$(printf '%s\n' "$credential_data" | sed -n 's/^password=//p')
curl -u "$gitea_user:$gitea_password" \
-X PATCH https://git.118im.com/api/v1/repos/im_team/olons_server \
-H 'Content-Type: application/json' \
--data '{"default_branch":"dev"}'
unset credential_data gitea_user gitea_password
```
认证信息仅从本机现有 Git credential helper 注入进程,不写入文件、不输出到日志。
Expected: HTTP 200,响应中的 `default_branch` 为 `dev`。
### Task 3: 验证手动入口并记录结果
**Files:**
- Create: `/Users/aa/.codex/memories/extensions/ad_hoc/notes/2026-10-13T14-34-07-olons-server-manual-actions.md`
- [ ] **Step 1: 重新运行远端契约断言**
Expected: PASS;默认分支为 `dev`,`.gitea/workflows/deploy.yml` 状态为 `active`。
- [ ] **Step 2: 确认没有因默认分支变更产生新的部署运行**
Run: 调用 `GET /api/v1/repos/im_team/olons_server/actions/runs?limit=5`。
Expected: 运行数量与变更前相同;本任务不触发 `workflow_dispatch`。
- [ ] **Step 3: 推送设计与计划提交**
Run:
```bash
git push origin dev
```
Expected: 本地 `dev` 与 `origin/dev` 一致。
- [ ] **Step 4: 更新记忆扩展说明**
记录默认分支变更、工作流可见性验证、未触发部署以及后续手动运行入口;不得记录凭据值。
@@ -0,0 +1,78 @@
# Customer Service IM Reliability Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Restore customer-service message and image delivery after WebSocket interruptions and route every browser IM request through its current frontend origin.
**Architecture:** Browser-facing session endpoints are relative paths resolved against the current origin, while server-to-server calls use Docker-internal endpoints. Caddy routes the IM API, file service, and WebSocket before each site's frontend fallback. The mobile chat client reauthenticates and resubscribes after SDK reconnects.
**Tech Stack:** Go, Vue 3, TypeScript, Tinode SDK, Vitest, Docker Compose, Caddy, Python/Bash deployment contract tests.
---
### Task 1: Relative browser endpoint contract
**Files:**
- Modify: `internal/support/service.go`
- Modify: `internal/support/service_test.go`
- [ ] Write a failing Go test asserting `OpenSession` returns `/v1/im`, `/v0/channels`, and `/v0/file` even when internal IM configuration contains an absolute host.
- [ ] Run `go test ./internal/support -run TestOpenSessionReturnsSameOriginEndpoints -v` and confirm the absolute-host assertion failure.
- [ ] Return the three relative public endpoints from `OpenSession` without changing `imclient.Config()` used for server calls.
- [ ] Re-run the targeted test and `go test ./internal/support ./internal/imclient ./internal/httpapi`.
### Task 2: Mobile reconnect and media authentication
**Files:**
- Modify: `web/package.json`
- Modify: `web/pnpm-lock.yaml`
- Modify: `web/src/mobile/im.ts`
- Create: `web/src/mobile/im.test.ts`
- Modify: `web/src/mobile/views/SupportView.vue`
- [ ] Add Vitest and a test script.
- [ ] Write failing tests for same-origin endpoint resolution, initial connect/login/subscribe, reconnect login/resubscribe with later data, and intentional disconnect.
- [ ] Run `pnpm test --run` and confirm the failures are caused by missing reconnect behavior.
- [ ] Add serialized reconnect recovery, connection-state callbacks, relative endpoint resolution, and Header-based upload authentication.
- [ ] Add an offline/reconnecting banner, disable sending while disconnected, and add media error/retry UI.
- [ ] Run `pnpm test --run`, `pnpm run build`, and the Go package tests.
### Task 3: Remove fixed IM public origins from im_webapp
**Files:**
- Modify: `../im_webapp/tests/test_actions_workflow.py`
- Modify: `../im_webapp/.gitea/workflows/deploy.yml`
- Modify: `../im_webapp/Dockerfile`
- Modify: `../im_webapp/web/src/lib/config.ts`
- Modify or create: `../im_webapp/web/tests/same-origin-endpoints.test.mjs`
- [ ] Change contract tests first so they reject fixed production API/file build arguments and the legacy default WebSocket host.
- [ ] Run the Python and Node tests and confirm they fail on the current fixed values.
- [ ] Remove fixed endpoint build arguments, make file base default empty, and derive the fallback WebSocket host from `window.location.host`.
- [ ] Run Python contracts, Node tests, and `pnpm run build`.
### Task 4: Caddy same-origin route contract
**Files:**
- Modify: `../deploy/olns-stack/test-deployment.sh`
- Modify: `../deploy/olns-stack/Caddyfile`
- Modify: `../deploy/olns-stack/compose.yaml`
- Modify: `.gitea/workflows/deploy.yml`
- [ ] Add failing deployment assertions requiring `/v1/im*`, `/v0/file*`, and `/v0/channels*` to be handled before frontend fallbacks for both site groups.
- [ ] Run `bash ../deploy/olns-stack/test-deployment.sh` and confirm failure.
- [ ] Add the Caddy route handlers with no-store responses.
- [ ] Switch server-side IM HTTP/WS/base values to `im-server:6060` in Compose and the OLONS deployment overlay.
- [ ] Re-run deployment contracts and workflow tests.
### Task 5: Integration and production verification
**Files:**
- Update: `../DEPLOYMENT_RECORD.md` with non-secret route and acceptance notes.
- [ ] Run full Go, web, workflow and deployment test suites and builds.
- [ ] Commit each repository, merge the task branch into current `pro`, and push `pro` only after all local gates pass.
- [ ] Back up `/opt/olns/Caddyfile` and Compose files, install the tested Caddy/Compose changes, validate Caddy, and reload atomically.
- [ ] Wait for both Actions deployments and verify image digests, container health and restart counts.
- [ ] Verify all five domains return IM responses instead of SPA HTML, WebSocket upgrades to 101, and file responses are not HTML.
- [ ] Exercise a real support session, interrupt/recover its network connection, and verify a subsequent text and image arrive without refreshing.
@@ -32,6 +32,13 @@
4. 将 Gitea Actions 工作流改为构建并推送 `git.118im.com/im_team/olons-server:test`。 4. 将 Gitea Actions 工作流改为构建并推送 `git.118im.com/im_team/olons-server:test`。
5. 工作流只滚动更新 `olons-server-system/olons-server`,不得操作现有 `workbench-system/im-workbench`。 5. 工作流只滚动更新 `olons-server-system/olons-server`,不得操作现有 `workbench-system/im-workbench`。
## 手动部署入口
- 保留 `dev` 分支现有 `.gitea/workflows/deploy.yml`,不在 `main` 复制第二份工作流。
- 将 Gitea 仓库默认分支从仅含初始化文件的 `main` 改为实际开发与部署分支 `dev`,使 Gitea Actions 识别并展示现有 `workflow_dispatch` 手动运行入口。
- 此设置变更本身不触发部署;只有用户在 Actions 页面手动运行工作流时,才会构建 `dev` 当前提交、推送测试镜像并滚动更新 `olons-server-system/olons-server`。
- 不修改 `olons_admin`;其默认分支 `main` 上已有可手动运行的部署工作流。
## GitOps 资源 ## GitOps 资源
在 `im-deploy-config` 中新增独立应用: 在 `im-deploy-config` 中新增独立应用:
@@ -0,0 +1,43 @@
# 客服 IM 投递可靠性设计
## 目标
修复 OLONS 会员端在 WebSocket 异常断线后无法继续接收客服文本或图片的问题,并保证 IM API、文件和 WebSocket 全部通过用户当前访问的前端域名转发。
## 范围
- `olons_server` 会员客服客户端的断线恢复、连接状态和媒体加载错误处理。
- `olons_server` 客服会话返回给浏览器的公开端点。
- `im_webapp` 生产构建中的固定公网域名。
- 单机 Compose Caddy 的同源路径路由和部署契约。
- 生产发布及五个前端域名的文本、图片、WebSocket 验收。
不修改会员、消息或文件业务数据,不改支付、提现及其他业务功能。
## 架构
浏览器公开端点使用相对路径:`/v1/im`、`/v0/file`、`/v0/channels`。客户端以 `window.location.origin` 解析 HTTP/文件地址,并将当前页面协议转换为 `ws` 或 `wss`。因此同一镜像在 `olons.yuexuan.shop` 和三个公测域名下会自动使用当前域名,不读取或信任任意 Host Header。
Caddy 在五个前端域名中优先把上述路径代理到 `im-server:6060`,其余路径仍进入原来的前端或 OLONS 服务。代理响应明确禁止缓存。`olons-server` 自身调用 IM 时使用 Docker 网络中的 `im-server:6060`,不再绕行 Cloudflare。
## 断线恢复
`CustomerServiceChat` 保存 token、当前 Topic 和最新 seq。首次连接仍执行 connect、token 登录和历史订阅。SDK 重新建立 WebSocket 后,客户端再次 token 登录,重新订阅当前 Topic,并通过 later-data 查询补拉本地最后 seq 之后的数据。恢复过程串行化,避免多个 reconnect 回调造成重复登录或订阅。
页面接收连接状态:`connecting`、`online`、`reconnecting`、`offline`。断线时保留已显示消息但禁止发送,恢复成功后解除禁止。主动离开页面不触发恢复。
## 图片处理
上传使用 `X-MeetingIM-APIKey` 和 `Authorization: Token <token>` Header,不再把 token 放在 URL 查询参数中。下载继续使用当前域名;图片或视频加载失败时显示失败提示和重试入口,不能静默显示空白。
## 测试
- TypeScript 单测覆盖相对端点解析、首次订阅、断线重连、重新登录、重新订阅、补拉和主动断开。
- Go 单测覆盖客服会话始终返回相对公开端点。
- `im_webapp` 工作流契约禁止固定 `im-api.yuexuan.shop` 和旧 `118im.com` 文件/WS地址。
- 部署契约要求五个域名都有三条同源路由,并保证 catch-all 顺序正确。
- 生产验收验证 JSON API、文件、WebSocket 101、断线恢复后的文本和图片,不以数据库序号代替浏览器展示验收。
## 回滚
应用镜像可回滚到发布前 digest;Caddy 修改前保留生产文件副本。回滚只涉及 `olons-server`、`im-webapp` 和 Caddy,不修改数据库或 MinIO 数据。
+4 -4
View File
@@ -31,12 +31,12 @@ Content-Type: application/json
"username": "alice01", "username": "alice01",
"phone": "13800138000", "phone": "13800138000",
"password": "Secret123", "password": "Secret123",
"referral_code": "ABCD2345", "referral_code": "123456",
"privacy_accepted": true "privacy_accepted": true
} }
``` ```
`referral_code` 可选;传入时必须匹配当前租户下正常会员的 `invite_code`。 `referral_code` 必填,必须是 6 位数字,且匹配当前租户下正常会员的 `invite_code`。邀请链接可通过 `inviteCode`、`invite_code` 或 `referral_code` 参数预填注册表单。
## 4 登录 ## 4 登录
@@ -64,7 +64,7 @@ Content-Type: application/json
"member_no": "MABCDEFGHIJKLMNOPQRST", "member_no": "MABCDEFGHIJKLMNOPQRST",
"username": "alice01", "username": "alice01",
"phone": "13800138000", "phone": "13800138000",
"invite_code": "EFGH6789", "invite_code": "654321",
"token": "...", "token": "...",
"token_type": "Bearer", "token_type": "Bearer",
"expires_at": "2026-10-23T10:00:00Z" "expires_at": "2026-10-23T10:00:00Z"
@@ -92,7 +92,7 @@ Authorization: Bearer <token>
| `USERNAME_INVALID_FORMAT` | 400 | 账户名格式错误 | | `USERNAME_INVALID_FORMAT` | 400 | 账户名格式错误 |
| `PHONE_INVALID_FORMAT` | 400 | 手机号格式错误 | | `PHONE_INVALID_FORMAT` | 400 | 手机号格式错误 |
| `PASSWORD_INVALID_FORMAT` | 400 | 密码格式错误 | | `PASSWORD_INVALID_FORMAT` | 400 | 密码格式错误 |
| `REFERRAL_CODE_INVALID` | 400 | 推荐码无效 | | `REFERRAL_CODE_INVALID` | 400 | 邀请码为空、格式错误或不存在 |
| `PRIVACY_POLICY_NOT_ACCEPTED` | 400 | 未同意隐私政策 | | `PRIVACY_POLICY_NOT_ACCEPTED` | 400 | 未同意隐私政策 |
| `ACCOUNT_ALREADY_REGISTERED` | 409 | 账户名或手机号已注册 | | `ACCOUNT_ALREADY_REGISTERED` | 409 | 账户名或手机号已注册 |
| `INVALID_CREDENTIALS` | 401 | 手机号或密码错误 | | `INVALID_CREDENTIALS` | 401 | 手机号或密码错误 |
@@ -2,12 +2,12 @@
> 版本:V1.0 > 版本:V1.0
> 更新日期:2026-10-09 > 更新日期:2026-10-09
> 适用项目:`im_workbench` > 适用项目:`olons_server`
> 数据源:MySQL `olons_admin` 中的 `hg_*` 业务表 > 数据源:MySQL `olons_admin` 中的 `hg_*` 业务表
## 1. 文档说明 ## 1. 文档说明
本文档用于移动端与 Workbench 后端联调。内容依据《移动端任务与账户平台需求文档》、当前代码实现和现有数据库表结构整理。 本文档用于移动端与 Olons Server 后端联调。内容依据《移动端任务与账户平台需求文档》、当前代码实现和现有数据库表结构整理。
接口分为两类: 接口分为两类:
@@ -54,7 +54,7 @@
| `Authorization: Bearer <token>` | 受保护接口必填 | 登录成功后取得的用户会话 | | `Authorization: Bearer <token>` | 受保护接口必填 | 登录成功后取得的用户会话 |
| `Idempotency-Key` | 取出提交必填 | 8–80 位请求唯一键;相同业务重试必须复用,不同请求禁止复用 | | `Idempotency-Key` | 取出提交必填 | 8–80 位请求唯一键;相同业务重试必须复用,不同请求禁止复用 |
`tenant_id` 由后端部署配置确定。客户端不得在 Header、Query 或 Body 中传入租户身份。 服务采用单租户模式,客户端不得在 Header、Query 或 Body 中传入租户身份。登录仅按手机号查找账号;数据库中的 `tenant_id` 仅作为兼容字段保留。
### 3.3 登录会话 ### 3.3 登录会话
@@ -167,7 +167,7 @@ Content-Type: application/json
"username": "alice01", "username": "alice01",
"phone": "13800138000", "phone": "13800138000",
"password": "Secret123", "password": "Secret123",
"referral_code": "ABCD2345", "referral_code": "123456",
"privacy_accepted": true "privacy_accepted": true
} }
``` ```
@@ -177,7 +177,7 @@ Content-Type: application/json
| `username` | string | 是 | 1–10 位小写字母或数字;后端去空格并转小写 | | `username` | string | 是 | 1–10 位小写字母或数字;后端去空格并转小写 |
| `phone` | string | 是 | 中国大陆 11 位手机号,不带 `+86` | | `phone` | string | 是 | 中国大陆 11 位手机号,不带 `+86` |
| `password` | string | 是 | 6–72 字节,不做 MD5 | | `password` | string | 是 | 6–72 字节,不做 MD5 |
| `referral_code` | string | 否 | 1–32 位字母或数字,必须匹配正常会员邀请码 | | `referral_code` | string | 是 | 6 位数字,必须匹配正常会员邀请码 |
| `privacy_accepted` | boolean | 是 | 必须为 `true` | | `privacy_accepted` | boolean | 是 | 必须为 `true` |
注册事务同时写入: 注册事务同时写入:
@@ -196,7 +196,7 @@ Content-Type: application/json
"member_no": "MABCDEFGHIJKLMNOPQRST", "member_no": "MABCDEFGHIJKLMNOPQRST",
"username": "alice01", "username": "alice01",
"phone": "13800138000", "phone": "13800138000",
"invite_code": "EFGH6789", "invite_code": "654321",
"token": "random-base64url-token", "token": "random-base64url-token",
"token_type": "Bearer", "token_type": "Bearer",
"expires_at": "2026-10-23T10:00:00Z" "expires_at": "2026-10-23T10:00:00Z"
@@ -241,7 +241,7 @@ Authorization: Bearer <token>
| 400 | `USERNAME_INVALID_FORMAT` | 账户名格式错误 | | 400 | `USERNAME_INVALID_FORMAT` | 账户名格式错误 |
| 400 | `PHONE_INVALID_FORMAT` | 手机号格式错误 | | 400 | `PHONE_INVALID_FORMAT` | 手机号格式错误 |
| 400 | `PASSWORD_INVALID_FORMAT` | 密码格式错误 | | 400 | `PASSWORD_INVALID_FORMAT` | 密码格式错误 |
| 400 | `REFERRAL_CODE_INVALID` | 推荐码无效 | | 400 | `REFERRAL_CODE_INVALID` | 邀请码为空、格式错误或不存在 |
| 400 | `PRIVACY_POLICY_NOT_ACCEPTED` | 未同意隐私政策 | | 400 | `PRIVACY_POLICY_NOT_ACCEPTED` | 未同意隐私政策 |
| 401 | `INVALID_CREDENTIALS` | 手机号或密码错误 | | 401 | `INVALID_CREDENTIALS` | 手机号或密码错误 |
| 409 | `ACCOUNT_ALREADY_REGISTERED` | 账户名、手机号或其他唯一字段冲突 | | 409 | `ACCOUNT_ALREADY_REGISTERED` | 账户名、手机号或其他唯一字段冲突 |
@@ -265,7 +265,7 @@ GET /v1/me
"username": "alice01", "username": "alice01",
"phone": "13800138000", "phone": "13800138000",
"avatar_url": "", "avatar_url": "",
"invite_code": "EFGH6789", "invite_code": "654321",
"level": { "level": {
"id": 1, "id": 1,
"code": "normal", "code": "normal",
@@ -303,7 +303,7 @@ PATCH /v1/me/avatar
} }
``` ```
附件必须是 `app_id=workbench`、归属当前会员、状态有效且 MIME 类型为图片的 `hg_sys_attachment` 记录。上传接口未实现前,前端不能自行生成可用附件 ID。 附件必须是 `app_id=olons_server`、归属当前会员、状态有效且 MIME 类型为图片的 `hg_sys_attachment` 记录。上传接口未实现前,前端不能自行生成可用附件 ID。
成功响应 `data`: 成功响应 `data`:
@@ -481,7 +481,7 @@ GET /v1/member-levels
GET /v1/leaderboard GET /v1/leaderboard
``` ```
接口要求登录,返回当前租户内已完成任务数量排名前 50 的有效会员。排名只统计 接口要求登录,返回当前租户内已完成领取订单总金额排名前 50 的有效会员。页面将订单总金额展示为领取积分,接口字段 `claimed_points` 的单位为分。排名只统计
`hg_trade_order.order_type=2 AND order_status=4` 的真实任务订单;昵称由服务端脱敏,前端不得还原或展示手机号。 `hg_trade_order.order_type=2 AND order_status=4` 的真实任务订单;昵称由服务端脱敏,前端不得还原或展示手机号。
```json ```json
@@ -492,7 +492,7 @@ GET /v1/leaderboard
"rank": 1, "rank": 1,
"username": "风***淡", "username": "风***淡",
"avatar_url": "https://cdn.example.com/avatar.png", "avatar_url": "https://cdn.example.com/avatar.png",
"completed_count": 12580, "claimed_points": 125800,
"status": "领取成功" "status": "领取成功"
} }
] ]
@@ -581,7 +581,7 @@ POST /v1/task-orders/claim
成功返回订单摘要,包括 `id`、`order_no`、`status`、`product`、`total_amount`、`commission_amount`、`currency` 和 `deadline_at`。 成功返回订单摘要,包括 `id`、`order_no`、`status`、`product`、`total_amount`、`commission_amount`、`currency` 和 `deadline_at`。
领单事务会锁定当前会员和钱包记录,重新校验大厅等级、账户状态、最低余额、当日次数、未完成订单、任务发布与有效期、任务每日上限及单会员上限。当前版本在事务内直接选中可用任务,创建 `status=pending` 的订单并记录 `matched_at`,客户端收到成功响应后即可进入订单详情并提交凭证;不依赖额外匹配进程。领取不扣款、不冻结余额,也不生成钱包流水。当前表结构没有任务与商品的直接绑定字段,因此 `product.id`、`total_amount` 和 `unit_price_amount` 允许为 `0`,任务名称和封面取自 `hg_task`。 领单事务会锁定当前会员和钱包记录,重新校验大厅等级、账户状态、最低余额、会员等级每日领取次数和未完成订单。校验通过后先创建 `status=matching`、`order_type=2` 的派单中订单,此时商品、数量、金额和佣金均为空或 `0`,与后台订单列表的“派单中/匹配订单”原型一致。会员的 `auto_dispatch=1` 时,达到网站设置的 `autoOrderDelaySeconds` 延时后由系统自动匹配:从当前已发布、在有效期内、币种为 CNY,且价格同时满足会员可用余额与 `autoOrderMinAmount`/`autoOrderMaxAmount` 区间的 `hg_product` 中随机选取商品,按会员当前等级版本计算佣金,并将订单原子更新为 `status=pending`。匹配成功时在同一事务内从可用余额扣除订单本金并转入冻结余额,同时生成可用余额支出和冻结余额收入流水。订单完成时释放冻结本金、返回可用余额并另行入账佣金;订单超时时也必须释放并退回本金。`auto_dispatch=2` 或自动匹配暂时找不到商品时,订单保持“派单中”,管理员可在后台通过“匹配订单”人工指定商品,人工匹配也执行相同的本金扣除和冻结流程。自动和人工匹配共用同一状态条件,并发时只有一方能更新成功。新注册会员默认开启自动派单,管理员可在会员编辑弹窗修改派单模式。
| HTTP | `reason` | 说明 | | HTTP | `reason` | 说明 |
| --- | --- | --- | | --- | --- | --- |
@@ -642,15 +642,12 @@ POST /v1/task-orders/{order_id}/submissions
``` ```
```json ```json
{ {}
"evidence_text": "已完成任务",
"evidence_attachment_ids": [1001, 1002]
}
``` ```
成功返回 `submission_no`、`status=reviewing`、`submitted_at` 和最新订单状态。重复提交不得生成多笔审核记录或佣金流水。 成功返回 `submission_no`、`status=settled`、`submitted_at` 和 `order_status=completed`。重复提交不得生成多笔提交记录或佣金流水。
仅 `pending` 订单允许提交。`evidence_text` 去除首尾空白后最长 2000 字符,附件最多 9 个,两者至少提供一项;附件必须属于当前租户和当前会员、`app_id=workbench`、状态有效,且类型为图片或 PDF。提交事务对订单加锁,超时订单自动置为 `expired`,有效提交创建唯一审核记录并把订单置为 `processing`。此步骤不生成佣金流水;审核通过并结算仍由后台流程负责。 正常情况下仅 `pending` 订单允许提交。前端按原型提供“立即提交”确认操作,不要求用户填写凭证。接口仍兼容可选的 `evidence_text` 和 `evidence_attachment_ids`:文字最长 2000 字符、附件最多 9 个;附件必须属于当前租户和当前会员、`app_id=olons_server`、状态有效,且类型为图片或 PDF。提交事务对订单、钱包加锁,超时订单自动置为 `expired`;有效提交创建唯一的已结算记录、佣金流水并增加可用余额,最后把订单置为 `completed`。后台审核页保留已结算记录供查询,冻结、强制处理和人工补偿只作为异常兜底。
订单详情返回: 订单详情返回:
@@ -659,7 +656,7 @@ POST /v1/task-orders/{order_id}/submissions
- `submission`:凭证、审核状态与驳回原因; - `submission`:凭证、审核状态与驳回原因;
- `timeline`:创建、匹配、提交、审核、结算、完成时间点; - `timeline`:创建、匹配、提交、审核、结算、完成时间点;
- `ledgers`:以订单号或提交号关联的钱包流水; - `ledgers`:以订单号或提交号关联的钱包流水;
- `allowed_actions`:待处理为 `submit`、`contact_support`,审核中/超时/冻结/失败仅为 `contact_support`。 - `allowed_actions`:待处理为 `submit`、`contact_support`,超时/冻结/失败仅为 `contact_support`;提交成功后不再提供重复提交操作。
### 7.6 任务统计 ### 7.6 任务统计
@@ -746,7 +743,7 @@ GET /v1/manual-products?page=1&page_size=20&category_id=0
} }
``` ```
金额单位为分。现有 `hg_product` 没有独立摘要字段,`summary` 由商品详情转换为纯文本后截取前 120 个字符。`claimed_count` 统计该商品 `order_type=3` 且状态不是已取消或失败的订单。`is_limited` 在商品配置了 `end_at` 时为 `true`,客户端使用该字段区分限时商品,不根据分类名称推断;`start_at`、`end_at` 为商品展示有效期。 金额单位为分。现有 `hg_product` 没有独立摘要字段,`summary` 由商品详情转换为纯文本后截取前 120 个字符。`claimed_count` 直接读取商品表中的固定展示数,不在页面或接口中重新计算。`is_limited` 在商品配置了 `end_at` 时为 `true`,客户端使用该字段区分限时商品,不根据分类名称推断;`start_at`、`end_at` 为商品展示有效期。
### 8.2 商品详情 ### 8.2 商品详情
@@ -857,7 +854,7 @@ GET /v1/wallet/ledgers?page=1&page_size=20&biz_type=task&direction=income
响应包含 `recharge_no`、`status`、`request_amount`、`payable_amount`、`currency`、`expire_at` 和经过校验的支付指引。不通过客服聊天发送临时个人收款账户。 响应包含 `recharge_no`、`status`、`request_amount`、`payable_amount`、`currency`、`expire_at` 和经过校验的支付指引。不通过客服聊天发送临时个人收款账户。
公开状态:`pending_payment`、`reviewing`、`processing`、`succeeded`、`failed`、`expired`、`cancelled`。失败、驳回或过期必须返回 `reason_code` 和 `reason_text`。 公开状态:`pending_payment`、`reviewing`、`processing`、`succeeded`、`failed`、`expired`、`cancelled`。当前人工审核流程创建后直接进入 `reviewing`;`pending_payment` 仅为后续接入真实支付通道和支付回调时保留。失败、驳回或过期必须返回 `reason_code` 和 `reason_text`。
## 11. 收款账户和取出(已实现,沙箱) ## 11. 收款账户和取出(已实现,沙箱)
@@ -870,36 +867,38 @@ GET /v1/wallet/ledgers?page=1&page_size=20&biz_type=task&direction=income
```http ```http
GET /v1/payment-accounts GET /v1/payment-accounts
POST /v1/payment-accounts POST /v1/payment-accounts
PUT /v1/payment-accounts
GET /v1/payment-accounts/details
POST /v1/attachments/images
``` ```
新增请求: 先分别上传微信和支付宝二维码。图片接口使用 `multipart/form-data`,字段名为 `file`,仅接受不超过 5MB 的 JPG、PNG、WebP,返回附件 `id` 和可访问的 `url`。随后提交新增请求:
```json ```json
{ {
"account_type": "bank_card",
"bank_code": "ICBC",
"bank_name": "中国工商银行", "bank_name": "中国工商银行",
"holder_name": "张三", "holder_name": "张三",
"identity_no": "11010119900101123X",
"account_no": "6222000000001234", "account_no": "6222000000001234",
"reserved_phone": "13800138000", "wechat_qr_attachment_id": 101,
"qr_attachment_id": 0, "alipay_qr_attachment_id": 102
"trade_password": "654321"
} }
``` ```
支持的账户类型: 在“取出信息”页面,`GET /v1/payment-accounts/details` 返回当前会员自己的原始绑定字段及二维码附件 ID;页面将首条默认账户回填到同一表单。修改时调用 `PUT /v1/payment-accounts`,请求体沿用新增字段并增加已有账户的 `id`。更新按租户、会员和账户 ID 校验归属,原记录 ID 保持不变;未更换二维码时可沿用已绑定的有效附件 ID。历史资料无法解密时不回填脱敏字符串,用户须重新填写敏感字段。
| `account_type` | 必填字段 | 说明 | 绑卡页面按原型固定为银行卡账户,字段规则如下:
| 字段 | 是否必填 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `bank_card` | `bank_code`、`bank_name`、`holder_name`、`account_no` | 卡号只允许 8–32 位数字,空格和连字符会被移除 | | `holder_name` | 是 | 2–64 个字符 |
| `digital_wallet` | `holder_name`、`account_no` | 通用数字钱包 | | `identity_no` | 是 | 15 位或 18 位身份证号,18 位末位允许 X |
| `unionpay` | `holder_name`、`account_no` | 银联账户 | | `account_no` | 是 | 8–32 位数字,空格和连字符会被移除 |
| `alipay` | `holder_name`、`account_no` | 后端固定渠道名称和编码 | | `bank_name` | 是 | 2–100 个字符 |
| `wechat` | `holder_name`、`account_no` | 后端固定渠道名称和编码 | | `wechat_qr_attachment_id` | 是 | 当前会员上传的有效图片附件 |
| `alipay_qr_attachment_id` | 是 | 当前会员上传的有效图片附件,不能与微信二维码共用同一附件 |
`reserved_phone` 可选,传入时必须为 11 位中国大陆手机号;`qr_attachment_id` 可选,传入时必须是当前会员在 `workbench` 应用下有效的图片附件。绑定前必须校验 6 位数字交易密码,连续错误 5 次后锁定 15 分钟。 姓名、身份证号和银行卡号使用 AES-256-GCM 独立随机 nonce 加密,身份证号和银行卡号另存 HMAC-SHA256 检索哈希。列表只返回脱敏信息,绝不返回明文或密文。绑定不要求支付密码;支付密码只在取出提交时校验。当前产品不设置银行卡后台审核流程,绑定成功后直接写入 `verify_status=verified`,可立即用于取出。
姓名、账号和手机号使用 AES-256-GCM 独立随机 nonce 加密,账号另存 HMAC-SHA256 检索哈希用于防重复。列表只返回脱敏信息,绝不返回明文或密文。新增账户初始 `verify_status=pending`,必须由后台核验为 `verified` 后才能用于取出。
列表项示例: 列表项示例:
@@ -907,13 +906,14 @@ POST /v1/payment-accounts
{ {
"id": 1, "id": 1,
"account_type": "bank_card", "account_type": "bank_card",
"bank_code": "ICBC", "bank_code": "BANK",
"bank_name": "中国工商银行", "bank_name": "中国工商银行",
"holder_mask": "张*", "holder_mask": "张*",
"mobile_mask": "138****8000", "identity_no_mask": "1101**********123X",
"account_no_mask": "6222********1234", "account_no_mask": "6222********1234",
"qr_attachment_id": 0, "wechat_qr_attachment_id": 101,
"verify_status": "pending", "alipay_qr_attachment_id": 102,
"verify_status": "verified",
"is_default": true, "is_default": true,
"created_at": "2026-10-09T10:00:00Z", "created_at": "2026-10-09T10:00:00Z",
"updated_at": "2026-10-09T10:00:00Z" "updated_at": "2026-10-09T10:00:00Z"
@@ -111,9 +111,9 @@
| **状态** | **用户可见文案** | **允许操作** | **系统约束** | | **状态** | **用户可见文案** | **允许操作** | **系统约束** |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 匹配中 | 正在匹配任务 | 取消等待 | 只允许一个匹配请求 | | 匹配中 | 正在匹配任务 | 取消等待 | 只允许一个匹配请求 |
| 待处理 | 请在截止时间前完成 | 查看详情、提交 | 倒计时与服务端时间一致 | | 待处理 | 请在截止时间前完成 | 查看详情、立即提交 | 倒计时与服务端时间一致 |
| 处理中 | 已提交,等待确认 | 查看详情、联系客服 | 防重复提交 | | 处理中 | 系统正在结算 | 查看详情、联系客服 | 防重复提交;正常情况为瞬时状态 |
| 已完成 | 本金与佣金已入账 | 查看流水 | 三笔或组合流水必须可核对 | | 已完成 | 佣金已入账 | 查看流水 | 订单、提交记录和佣金流水必须可核对 |
| 超时或冻结 | 显示原因与处理路径 | 申诉、联系客服 | 需要人工复核和审计 | | 超时或冻结 | 显示原因与处理路径 | 申诉、联系客服 | 需要人工复核和审计 |
| 失败或取消 | 显示失败原因 | 重新领取 | 不得产生不一致余额 | | 失败或取消 | 显示失败原因 | 重新领取 | 不得产生不一致余额 |
+16 -1
View File
@@ -1,10 +1,12 @@
module git.118im.com/im_team/im_workbench module git.118im.com/im_team/olons_server
go 1.26.0 go 1.26.0
require ( require (
github.com/go-sql-driver/mysql v1.10.0 github.com/go-sql-driver/mysql v1.10.0
github.com/gorilla/websocket v1.5.3
github.com/jmoiron/sqlx v1.4.0 github.com/jmoiron/sqlx v1.4.0
github.com/minio/minio-go/v7 v7.0.94
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.12.1 github.com/redis/go-redis/v9 v9.12.1
golang.org/x/crypto v0.53.0 golang.org/x/crypto v0.53.0
@@ -15,12 +17,25 @@ require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/minio/crc64nvme v1.0.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.46.0 // indirect golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect google.golang.org/protobuf v1.36.8 // indirect
) )
+29
View File
@@ -14,15 +14,28 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -33,8 +46,16 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/minio/crc64nvme v1.0.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY=
github.com/minio/crc64nvme v1.0.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.94 h1:1ZoksIKPyaSt64AVOyaQvhDOgVC3MfZsWM6mZXRUGtM=
github.com/minio/minio-go/v7 v7.0.94/go.mod h1:71t2CqDt3ThzESgZUlU1rBN54mksGGlkLcFgguDnnAc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
@@ -49,16 +70,24 @@ github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgv
github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+4 -4
View File
@@ -5,7 +5,7 @@ import (
"errors" "errors"
"testing" "testing"
"git.118im.com/im_team/im_workbench/internal/store" "git.118im.com/im_team/olons_server/internal/store"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -19,7 +19,7 @@ type profileRepository struct {
func (r *profileRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) { func (r *profileRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
return nil, errors.New("not implemented") return nil, errors.New("not implemented")
} }
func (r *profileRepository) AccountByMobile(context.Context, int64, string) (*store.Account, error) { func (r *profileRepository) AccountByMobile(context.Context, string) (*store.Account, error) {
return nil, errors.New("not implemented") return nil, errors.New("not implemented")
} }
func (r *profileRepository) RecordLogin(context.Context, int64, int64) error { return nil } func (r *profileRepository) RecordLogin(context.Context, int64, int64) error { return nil }
@@ -44,14 +44,14 @@ func (r *profileRepository) UpdateTradePassword(_ context.Context, _, _ int64, _
func TestProfileMapsMemberCategory(t *testing.T) { func TestProfileMapsMemberCategory(t *testing.T) {
repository := &profileRepository{overview: &store.MemberOverview{ repository := &profileRepository{overview: &store.MemberOverview{
MemberID: 7, MemberNo: "M0007", Username: "robot", MemberStatus: 1, MemberCategory: 2, MemberID: 7, MemberNo: "M0007", Username: "robot", MemberStatus: 1, MemberCategory: 2,
Mobile: "13800138000", Currency: "CNY", WalletStatus: 1, Mobile: "13800138000", Currency: "CNY", WalletStatus: 1, HasTradePassword: 1,
}} }}
service := &Service{repository: repository, ready: true} service := &Service{repository: repository, ready: true}
profile, err := service.Profile(context.Background(), 0, 7) profile, err := service.Profile(context.Background(), 0, 7)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if profile.Phone != "13800138000" || profile.MemberCategory != "fake" || profile.MemberStatus != "active" || profile.Wallet.Status != "active" { if profile.Phone != "13800138000" || profile.MemberCategory != "fake" || profile.MemberStatus != "active" || profile.Wallet.Status != "active" || !profile.HasTradePassword {
t.Fatalf("unexpected profile states: %+v", profile) t.Fatalf("unexpected profile states: %+v", profile)
} }
} }
+69 -38
View File
@@ -8,13 +8,16 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"math/big"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "time"
"unicode"
"unicode/utf8"
"git.118im.com/im_team/im_workbench/internal/config" "git.118im.com/im_team/olons_server/internal/config"
"git.118im.com/im_team/im_workbench/internal/store" "git.118im.com/im_team/olons_server/internal/store"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -36,15 +39,14 @@ var (
) )
var ( var (
usernamePattern = regexp.MustCompile(`^[a-z0-9]{1,10}$`)
phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`) phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`)
referralPattern = regexp.MustCompile(`^[A-Za-z0-9]{1,32}$`) referralPattern = regexp.MustCompile(`^[0-9]{6}$`)
tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`) tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`)
) )
type Repository interface { type Repository interface {
RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error)
AccountByMobile(context.Context, int64, string) (*store.Account, error) AccountByMobile(context.Context, string) (*store.Account, error)
RecordLogin(context.Context, int64, int64) error RecordLogin(context.Context, int64, int64) error
MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error) MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error)
UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error) UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error)
@@ -75,17 +77,18 @@ type Result struct {
} }
type Profile struct { type Profile struct {
MemberID int64 `json:"member_id"` MemberID int64 `json:"member_id"`
MemberNo string `json:"member_no"` MemberNo string `json:"member_no"`
Username string `json:"username"` Username string `json:"username"`
Phone string `json:"phone"` Phone string `json:"phone"`
AvatarURL string `json:"avatar_url"` AvatarURL string `json:"avatar_url"`
InviteCode string `json:"invite_code"` InviteCode string `json:"invite_code"`
Level ProfileLevel `json:"level"` HasTradePassword bool `json:"has_trade_password"`
MemberStatus string `json:"member_status"` Level ProfileLevel `json:"level"`
MemberCategory string `json:"member_category"` MemberStatus string `json:"member_status"`
CreditScore int `json:"credit_score"` MemberCategory string `json:"member_category"`
Wallet ProfileWallet `json:"wallet"` CreditScore int `json:"credit_score"`
Wallet ProfileWallet `json:"wallet"`
} }
type ProfileLevel struct { type ProfileLevel struct {
@@ -142,10 +145,10 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, e
if !s.ready { if !s.ready {
return nil, ErrUnavailable return nil, ErrUnavailable
} }
input.Username = strings.ToLower(strings.TrimSpace(input.Username)) input.Username = strings.TrimSpace(input.Username)
input.Phone = strings.TrimSpace(input.Phone) input.Phone = strings.TrimSpace(input.Phone)
input.ReferralCode = strings.TrimSpace(input.ReferralCode) input.ReferralCode = strings.TrimSpace(input.ReferralCode)
if !usernamePattern.MatchString(input.Username) { if !validMemberName(input.Username) {
return nil, ErrUsernameInvalid return nil, ErrUsernameInvalid
} }
if !phonePattern.MatchString(input.Phone) { if !phonePattern.MatchString(input.Phone) {
@@ -154,7 +157,7 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, e
if len([]rune(input.Password)) < 6 || len([]byte(input.Password)) > 72 { if len([]rune(input.Password)) < 6 || len([]byte(input.Password)) > 72 {
return nil, ErrPasswordInvalid return nil, ErrPasswordInvalid
} }
if input.ReferralCode != "" && !referralPattern.MatchString(input.ReferralCode) { if !referralPattern.MatchString(input.ReferralCode) {
return nil, ErrReferralInvalid return nil, ErrReferralInvalid
} }
if !input.PrivacyAccepted { if !input.PrivacyAccepted {
@@ -169,26 +172,53 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, e
if err != nil { if err != nil {
return nil, err return nil, err
} }
inviteCode, err := newPublicID("", 8) for attempt := 0; attempt < 10; attempt++ {
if err != nil { inviteCode, err := newInviteCode()
return nil, err if err != nil {
} return nil, err
account, err := s.repository.RegisterAccount(ctx, store.AccountRegistration{ }
TenantID: s.cfg.DefaultTenantID, account, err := s.repository.RegisterAccount(ctx, store.AccountRegistration{
MemberNo: memberNo, TenantID: 0,
Username: input.Username, MemberNo: memberNo,
Mobile: input.Phone, Username: input.Username,
PasswordHash: string(passwordHash), Mobile: input.Phone,
ReferralCode: input.ReferralCode, PasswordHash: string(passwordHash),
InviteCode: inviteCode, ReferralCode: input.ReferralCode,
}) InviteCode: inviteCode,
if err != nil { })
if errors.Is(err, store.ErrInviteCodeExists) {
continue
}
if errors.Is(err, store.ErrReferralInvalid) { if errors.Is(err, store.ErrReferralInvalid) {
return nil, ErrReferralInvalid return nil, ErrReferralInvalid
} }
return nil, err if err != nil {
return nil, err
}
return s.issueSession(ctx, account, input.DeviceID)
} }
return s.issueSession(ctx, account, input.DeviceID) return nil, ErrUnavailable
}
func newInviteCode() (string, error) {
number, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", number.Int64()), nil
}
func validMemberName(value string) bool {
count := utf8.RuneCountInString(value)
if count < 1 || count > 10 {
return false
}
for _, r := range value {
if !unicode.IsLetter(r) && !unicode.IsMark(r) && r != '·' {
return false
}
}
return true
} }
func (s *Service) Login(ctx context.Context, input LoginInput) (*Result, error) { func (s *Service) Login(ctx context.Context, input LoginInput) (*Result, error) {
@@ -206,7 +236,7 @@ func (s *Service) Login(ctx context.Context, input LoginInput) (*Result, error)
if attempts, err := s.redis.Get(ctx, rateKey).Int(); err == nil && attempts >= 10 { if attempts, err := s.redis.Get(ctx, rateKey).Int(); err == nil && attempts >= 10 {
return nil, ErrRateLimited return nil, ErrRateLimited
} }
account, err := s.repository.AccountByMobile(ctx, s.cfg.DefaultTenantID, phone) account, err := s.repository.AccountByMobile(ctx, phone)
if err != nil { if err != nil {
if errors.Is(err, store.ErrInvalidCredentials) { if errors.Is(err, store.ErrInvalidCredentials) {
s.recordLoginFailure(ctx, rateKey) s.recordLoginFailure(ctx, rateKey)
@@ -238,6 +268,7 @@ func (s *Service) Profile(ctx context.Context, tenantID, memberID int64) (*Profi
return &Profile{ return &Profile{
MemberID: row.MemberID, MemberNo: row.MemberNo, Username: row.Username, MemberID: row.MemberID, MemberNo: row.MemberNo, Username: row.Username,
Phone: row.Mobile, AvatarURL: row.AvatarURL, InviteCode: row.InviteCode, Phone: row.Mobile, AvatarURL: row.AvatarURL, InviteCode: row.InviteCode,
HasTradePassword: row.HasTradePassword != 0,
Level: ProfileLevel{ID: row.LevelID, Code: row.LevelCode, Name: row.LevelName, Level: ProfileLevel{ID: row.LevelID, Code: row.LevelCode, Name: row.LevelName,
IconURL: row.LevelIconURL, CommissionRate: row.CommissionRate}, IconURL: row.LevelIconURL, CommissionRate: row.CommissionRate},
MemberStatus: memberStatusName(row.MemberStatus), MemberCategory: memberCategoryName(row.MemberCategory), MemberStatus: memberStatusName(row.MemberStatus), MemberCategory: memberCategoryName(row.MemberCategory),
@@ -355,7 +386,7 @@ func (s *Service) issueSession(ctx context.Context, account *store.Account, devi
func (s *Service) loginRateKey(phone string) string { func (s *Service) loginRateKey(phone string) string {
digest := sha256.Sum256([]byte(phone)) digest := sha256.Sum256([]byte(phone))
return "workbench:auth:login-rate:" + strconv.FormatInt(s.cfg.DefaultTenantID, 10) + ":" + hex.EncodeToString(digest[:]) return "olons_server:auth:login-rate:" + hex.EncodeToString(digest[:])
} }
func (s *Service) recordLoginFailure(ctx context.Context, key string) { func (s *Service) recordLoginFailure(ctx context.Context, key string) {
+28 -4
View File
@@ -6,7 +6,7 @@ import (
"strings" "strings"
"testing" "testing"
"git.118im.com/im_team/im_workbench/internal/store" "git.118im.com/im_team/olons_server/internal/store"
) )
type fakeRepository struct{} type fakeRepository struct{}
@@ -14,7 +14,7 @@ type fakeRepository struct{}
func (fakeRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) { func (fakeRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
return nil, errors.New("should not be called") return nil, errors.New("should not be called")
} }
func (fakeRepository) AccountByMobile(context.Context, int64, string) (*store.Account, error) { func (fakeRepository) AccountByMobile(context.Context, string) (*store.Account, error) {
return nil, errors.New("should not be called") return nil, errors.New("should not be called")
} }
func (fakeRepository) RecordLogin(context.Context, int64, int64) error { func (fakeRepository) RecordLogin(context.Context, int64, int64) error {
@@ -43,11 +43,13 @@ func TestRegisterValidation(t *testing.T) {
input RegisterInput input RegisterInput
want error want error
}{ }{
{name: "username", input: RegisterInput{Username: "中文", Phone: "13800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrUsernameInvalid}, {name: "username", input: RegisterInput{Username: "张 三", Phone: "13800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrUsernameInvalid},
{name: "phone", input: RegisterInput{Username: "alice", Phone: "12800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrPhoneInvalid}, {name: "phone", input: RegisterInput{Username: "alice", Phone: "12800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrPhoneInvalid},
{name: "password", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "123", PrivacyAccepted: true}, want: ErrPasswordInvalid}, {name: "password", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "123", PrivacyAccepted: true}, want: ErrPasswordInvalid},
{name: "referral required", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1", PrivacyAccepted: true}, want: ErrReferralInvalid},
{name: "referral", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1", ReferralCode: "bad!", PrivacyAccepted: true}, want: ErrReferralInvalid}, {name: "referral", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1", ReferralCode: "bad!", PrivacyAccepted: true}, want: ErrReferralInvalid},
{name: "privacy", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1"}, want: ErrPrivacyNotAccepted}, {name: "referral length", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1", ReferralCode: "12345", PrivacyAccepted: true}, want: ErrReferralInvalid},
{name: "privacy", input: RegisterInput{Username: "alice", Phone: "13800138000", Password: "secret1", ReferralCode: "123456"}, want: ErrPrivacyNotAccepted},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
@@ -59,6 +61,28 @@ func TestRegisterValidation(t *testing.T) {
} }
} }
func TestNewInviteCodeIsSixDigits(t *testing.T) {
for i := 0; i < 100; i++ {
code, err := newInviteCode()
if err != nil || !referralPattern.MatchString(code) {
t.Fatalf("newInviteCode() = %q, %v", code, err)
}
}
}
func TestValidMemberNameSupportsChinese(t *testing.T) {
for _, name := range []string{"杨情", "欧阳娜娜", "Alicia", "阿依古丽·买买提"} {
if !validMemberName(name) {
t.Errorf("validMemberName(%q) = false, want true", name)
}
}
for _, name := range []string{"", "张 三", "user01", "这是一个超过十个字符长度的名字"} {
if validMemberName(name) {
t.Errorf("validMemberName(%q) = true, want false", name)
}
}
}
func TestLoginRateKeyDoesNotExposePhone(t *testing.T) { func TestLoginRateKeyDoesNotExposePhone(t *testing.T) {
s := &Service{} s := &Service{}
key := s.loginRateKey("13800138000") key := s.loginRateKey("13800138000")
+41 -14
View File
@@ -8,15 +8,18 @@ import (
"strings" "strings"
"time" "time"
"git.118im.com/im_team/im_workbench/internal/accounts" "git.118im.com/im_team/olons_server/internal/accounts"
"git.118im.com/im_team/im_workbench/internal/auth" "git.118im.com/im_team/olons_server/internal/attachments"
"git.118im.com/im_team/im_workbench/internal/config" "git.118im.com/im_team/olons_server/internal/auth"
"git.118im.com/im_team/im_workbench/internal/finance" "git.118im.com/im_team/olons_server/internal/config"
"git.118im.com/im_team/im_workbench/internal/httpapi" "git.118im.com/im_team/olons_server/internal/finance"
"git.118im.com/im_team/im_workbench/internal/mall" "git.118im.com/im_team/olons_server/internal/httpapi"
"git.118im.com/im_team/im_workbench/internal/portal" "git.118im.com/im_team/olons_server/internal/imclient"
"git.118im.com/im_team/im_workbench/internal/store" "git.118im.com/im_team/olons_server/internal/mall"
"git.118im.com/im_team/im_workbench/internal/tasks" "git.118im.com/im_team/olons_server/internal/portal"
"git.118im.com/im_team/olons_server/internal/store"
"git.118im.com/im_team/olons_server/internal/support"
"git.118im.com/im_team/olons_server/internal/tasks"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
) )
@@ -26,6 +29,7 @@ type App struct {
redis *redis.Client redis *redis.Client
api *httpapi.API api *httpapi.API
frontendPath string frontendPath string
upload config.UploadConfig
} }
func New(cfg *config.Config) (*App, error) { func New(cfg *config.Config) (*App, error) {
@@ -49,14 +53,37 @@ func New(cfg *config.Config) (*App, error) {
taskService := tasks.New(st) taskService := tasks.New(st)
mallService := mall.New(st) mallService := mall.New(st)
financeService := finance.New(st, redisClient, cfg.Auth) financeService := finance.New(st, redisClient, cfg.Auth)
return &App{store: st, redis: redisClient, api: httpapi.New(authenticator, accountService, portalService, taskService, mallService, financeService), frontendPath: cfg.FrontendDir}, nil uploadConfig := cfg.Upload
uploadCtx, uploadCancel := context.WithTimeout(context.Background(), 5*time.Second)
uploadValues, err := st.UploadConfigValues(uploadCtx)
uploadCancel()
if err != nil {
_ = redisClient.Close()
_ = st.Close()
return nil, err
}
config.ApplyUploadSystemConfig(&uploadConfig, uploadValues)
config.ApplyUploadEnv(&uploadConfig)
if err = config.ValidateUpload(uploadConfig); err != nil {
_ = redisClient.Close()
_ = st.Close()
return nil, err
}
attachmentService, err := attachments.New(st, uploadConfig)
if err != nil {
_ = redisClient.Close()
_ = st.Close()
return nil, err
}
supportService := support.New(st, imclient.New(cfg.IM), cfg.Auth)
return &App{store: st, redis: redisClient, api: httpapi.New(authenticator, accountService, portalService, taskService, mallService, financeService, attachmentService, supportService), frontendPath: cfg.FrontendDir, upload: uploadConfig}, nil
} }
func (a *App) Handler() http.Handler { func (a *App) Handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle(strings.TrimRight(a.upload.PublicPath, "/")+"/", http.StripPrefix(strings.TrimRight(a.upload.PublicPath, "/"), http.FileServer(http.Dir(a.upload.Dir))))
if a.frontendDir() != "" { if a.frontendDir() != "" {
mux.Handle("/admin", frontendHandler(a.frontendDir())) mux.Handle("/", frontendHandler(a.frontendDir()))
mux.Handle("/admin/", frontendHandler(a.frontendDir()))
} }
mux.HandleFunc("/healthz", a.api.Healthz) mux.HandleFunc("/healthz", a.api.Healthz)
mux.Handle("/metrics", promhttp.Handler()) mux.Handle("/metrics", promhttp.Handler())
@@ -65,7 +92,7 @@ func (a *App) Handler() http.Handler {
} }
func (a *App) frontendDir() string { func (a *App) frontendDir() string {
if dir := strings.TrimSpace(os.Getenv("WORKBENCH_FRONTEND_DIR")); dir != "" { if dir := strings.TrimSpace(os.Getenv("OLONS_SERVER_FRONTEND_DIR")); dir != "" {
return dir return dir
} }
return strings.TrimSpace(a.frontendPath) return strings.TrimSpace(a.frontendPath)
@@ -75,7 +102,7 @@ func frontendHandler(dir string) http.Handler {
files := http.FileServer(http.Dir(dir)) files := http.FileServer(http.Dir(dir))
index := filepath.Join(dir, "index.html") index := filepath.Join(dir, "index.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/admin") path := r.URL.Path
if path == "" { if path == "" {
path = "/" path = "/"
} }
+124
View File
@@ -0,0 +1,124 @@
package attachments
import (
"bytes"
"context"
"crypto/md5"
"crypto/rand"
"encoding/hex"
"errors"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"time"
"git.118im.com/im_team/olons_server/internal/config"
"git.118im.com/im_team/olons_server/internal/store"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
const maxImageSize = 5 << 20
var ErrImageInvalid = errors.New("image attachment invalid")
type Repository interface {
CreateAttachment(context.Context, store.AttachmentCreation) (*store.Attachment, error)
}
type Service struct {
repository Repository
cfg config.UploadConfig
minio *minio.Client
}
func New(repository Repository, cfg config.UploadConfig) (*Service, error) {
service := &Service{repository: repository, cfg: cfg}
if strings.EqualFold(cfg.Driver, "minio") {
client, err := minio.New(strings.TrimSpace(cfg.Endpoint), &minio.Options{
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
Secure: cfg.Secure,
Region: cfg.Region,
})
if err != nil {
return nil, err
}
service.minio = client
}
return service, nil
}
func (s *Service) UploadImage(ctx context.Context, memberID int64, name string, reader io.Reader) (*store.Attachment, error) {
if s.repository == nil || memberID <= 0 {
return nil, ErrImageInvalid
}
data, err := io.ReadAll(io.LimitReader(reader, maxImageSize+1))
if err != nil || len(data) == 0 || len(data) > maxImageSize {
return nil, ErrImageInvalid
}
mimeType := http.DetectContentType(data)
extensions := map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
ext, ok := extensions[mimeType]
if !ok {
return nil, ErrImageInvalid
}
random := make([]byte, 16)
if _, err = rand.Read(random); err != nil {
return nil, err
}
fileName := hex.EncodeToString(random) + ext
storedPath, publicURL, err := s.store(ctx, fileName, mimeType, data)
if err != nil {
return nil, err
}
digest := md5.Sum(data)
attachment, err := s.repository.CreateAttachment(ctx, store.AttachmentCreation{
MemberID: memberID, Drive: strings.ToLower(strings.TrimSpace(s.cfg.Driver)), Name: filepath.Base(name), MimeType: mimeType, Path: storedPath,
URL: publicURL, Size: int64(len(data)), Ext: strings.TrimPrefix(ext, "."), MD5: hex.EncodeToString(digest[:]),
})
if err != nil {
s.remove(ctx, storedPath)
return nil, err
}
return attachment, nil
}
func (s *Service) store(ctx context.Context, fileName, mimeType string, data []byte) (string, string, error) {
if s.minio != nil {
objectName := path.Join(strings.Trim(s.cfg.Path, "/"), time.Now().UTC().Format("2006/01/02"), fileName)
_, err := s.minio.PutObject(ctx, s.cfg.Bucket, objectName, bytes.NewReader(data), int64(len(data)), minio.PutObjectOptions{ContentType: mimeType})
if err != nil {
return "", "", err
}
return objectName, minioPublicURL(s.cfg.Domain, s.cfg.Bucket, objectName), nil
}
if err := os.MkdirAll(s.cfg.Dir, 0o750); err != nil {
return "", "", err
}
localPath := filepath.Join(s.cfg.Dir, fileName)
if err := os.WriteFile(localPath, data, 0o640); err != nil {
return "", "", err
}
return localPath, strings.TrimRight(s.cfg.PublicPath, "/") + "/" + fileName, nil
}
func (s *Service) remove(ctx context.Context, storedPath string) {
if s.minio != nil {
_ = s.minio.RemoveObject(ctx, s.cfg.Bucket, storedPath, minio.RemoveObjectOptions{})
return
}
_ = os.Remove(storedPath)
}
func minioPublicURL(domain, bucket, objectName string) string {
base := strings.TrimRight(strings.TrimSpace(domain), "/")
segments := strings.Split(strings.Trim(objectName, "/"), "/")
for i := range segments {
segments[i] = url.PathEscape(segments[i])
}
return base + "/" + url.PathEscape(bucket) + "/" + strings.Join(segments, "/")
}
+11
View File
@@ -0,0 +1,11 @@
package attachments
import "testing"
func TestMinioPublicURL(t *testing.T) {
got := minioPublicURL("https://minio.example.com/", "bucket", "images/2026/10/17/a b.jpg")
want := "https://minio.example.com/bucket/images/2026/10/17/a%20b.jpg"
if got != want {
t.Fatalf("minioPublicURL() = %q, want %q", got, want)
}
}
+3 -7
View File
@@ -13,7 +13,7 @@ import (
"strings" "strings"
"time" "time"
"git.118im.com/im_team/im_workbench/internal/config" "git.118im.com/im_team/olons_server/internal/config"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
) )
@@ -46,17 +46,13 @@ func New(cfg config.AuthConfig, redisClient *redis.Client) *Authenticator {
return &Authenticator{cfg: cfg, redis: redisClient} return &Authenticator{cfg: cfg, redis: redisClient}
} }
func (a *Authenticator) DefaultTenantID() int64 {
return a.cfg.DefaultTenantID
}
func (a *Authenticator) Authenticate(req *http.Request) (*Principal, error) { func (a *Authenticator) Authenticate(req *http.Request) (*Principal, error) {
if err := a.ValidateAPIKey(req); err != nil { if err := a.ValidateAPIKey(req); err != nil {
return nil, err return nil, err
} }
if a.cfg.AllowDevHeaderAuth { if a.cfg.AllowDevHeaderAuth {
if memberID := parseInt64Header(req, "X-Member-ID"); memberID > 0 { if memberID := parseInt64Header(req, "X-Member-ID"); memberID > 0 {
return &Principal{TenantID: a.cfg.DefaultTenantID, MemberID: memberID, return &Principal{TenantID: 0, MemberID: memberID,
DeviceID: strings.TrimSpace(req.Header.Get("X-Device-ID"))}, nil DeviceID: strings.TrimSpace(req.Header.Get("X-Device-ID"))}, nil
} }
} }
@@ -170,7 +166,7 @@ func (a *Authenticator) validateMemberSession(ctx context.Context, session *Sess
} }
func memberRevokedBeforeKey(tenantID, memberID int64) string { func memberRevokedBeforeKey(tenantID, memberID int64) string {
return "workbench:session:member-revoked-before:" + strconv.FormatInt(tenantID, 10) + ":" + strconv.FormatInt(memberID, 10) return "olons_server:session:member-revoked-before:" + strconv.FormatInt(tenantID, 10) + ":" + strconv.FormatInt(memberID, 10)
} }
func sessionKeyFromToken(pattern, token string) (string, error) { func sessionKeyFromToken(pattern, token string) (string, error) {
+3 -3
View File
@@ -9,18 +9,18 @@ import (
func TestSessionKeyFromToken(t *testing.T) { func TestSessionKeyFromToken(t *testing.T) {
token := base64.RawURLEncoding.EncodeToString(make([]byte, 32)) token := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
key, err := sessionKeyFromToken("workbench:session:token:%s", token) key, err := sessionKeyFromToken("olons_server:session:token:%s", token)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !strings.HasPrefix(key, "workbench:session:token:") || len(key) != len("workbench:session:token:")+64 { if !strings.HasPrefix(key, "olons_server:session:token:") || len(key) != len("olons_server:session:token:")+64 {
t.Fatalf("unexpected session key %q", key) t.Fatalf("unexpected session key %q", key)
} }
} }
func TestSessionKeyFromTokenRejectsMalformedToken(t *testing.T) { func TestSessionKeyFromTokenRejectsMalformedToken(t *testing.T) {
for _, token := range []string{"", "invalid", base64.RawURLEncoding.EncodeToString(make([]byte, 31))} { for _, token := range []string{"", "invalid", base64.RawURLEncoding.EncodeToString(make([]byte, 31))} {
if _, err := sessionKeyFromToken("workbench:session:token:%s", token); !errors.Is(err, ErrAuthRequired) { if _, err := sessionKeyFromToken("olons_server:session:token:%s", token); !errors.Is(err, ErrAuthRequired) {
t.Errorf("token %q: error = %v, want %v", token, err, ErrAuthRequired) t.Errorf("token %q: error = %v, want %v", token, err, ErrAuthRequired)
} }
} }
+151 -38
View File
@@ -18,10 +18,38 @@ type Config struct {
MySQL MySQLConfig `json:"mysql"` MySQL MySQLConfig `json:"mysql"`
Redis RedisConfig `json:"redis"` Redis RedisConfig `json:"redis"`
Auth AuthConfig `json:"auth"` Auth AuthConfig `json:"auth"`
Upload UploadConfig `json:"upload"`
IM IMConfig `json:"im"`
ReadTimeout time.Duration `json:"-"` ReadTimeout time.Duration `json:"-"`
WriteTimeout time.Duration `json:"-"` WriteTimeout time.Duration `json:"-"`
} }
type IMConfig struct {
BaseURL string `json:"base_url"`
HTTPAPIURL string `json:"http_api_url"`
WebSocketURL string `json:"websocket_url"`
FileURL string `json:"file_url"`
TenantCode string `json:"tenant_code"`
APIKey string `json:"api_key"`
InternalService string `json:"internal_service"`
InternalSecret string `json:"internal_secret"`
Timeout time.Duration `json:"-"`
}
type UploadConfig struct {
Driver string `json:"driver"`
Dir string `json:"dir"`
PublicPath string `json:"public_path"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
Endpoint string `json:"endpoint"`
Secure bool `json:"secure"`
Path string `json:"path"`
Bucket string `json:"bucket"`
Domain string `json:"domain"`
Region string `json:"region"`
}
type MySQLConfig struct { type MySQLConfig struct {
DSN string `json:"dsn"` DSN string `json:"dsn"`
MaxOpenConns int `json:"max_open_conns"` MaxOpenConns int `json:"max_open_conns"`
@@ -36,19 +64,20 @@ type RedisConfig struct {
} }
type AuthConfig struct { type AuthConfig struct {
APIKeys []string `json:"api_keys"` APIKeys []string `json:"api_keys"`
SessionKeyPattern string `json:"session_key_pattern"` SessionKeyPattern string `json:"session_key_pattern"`
SessionTTLSeconds int `json:"session_ttl_seconds"` SessionTTLSeconds int `json:"session_ttl_seconds"`
DefaultTenantID int64 `json:"default_tenant_id"` DataEncryptionKey string `json:"data_encryption_key"`
DataEncryptionKey string `json:"data_encryption_key"` LookupHMACKey string `json:"lookup_hmac_key"`
LookupHMACKey string `json:"lookup_hmac_key"` FinanceMasterKey string `json:"finance_master_key"`
AllowDevHeaderAuth bool `json:"allow_dev_header_auth"` FinanceLegacyMasterKeys []string `json:"finance_legacy_master_keys"`
RequiredAPIKey bool `json:"required_api_key"` AllowDevHeaderAuth bool `json:"allow_dev_header_auth"`
RequiredAPIKey bool `json:"required_api_key"`
} }
func Load() (*Config, error) { func Load() (*Config, error) {
cfg := defaultConfig() cfg := defaultConfig()
if path := strings.TrimSpace(os.Getenv("WORKBENCH_CONFIG")); path != "" { if path := strings.TrimSpace(os.Getenv("OLONS_SERVER_CONFIG")); path != "" {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -57,7 +86,11 @@ func Load() (*Config, error) {
return nil, err return nil, err
} }
} }
configuredFinanceMasterKey := strings.TrimSpace(cfg.Auth.FinanceMasterKey)
applyEnv(cfg) applyEnv(cfg)
if configuredFinanceMasterKey != "" && configuredFinanceMasterKey != strings.TrimSpace(cfg.Auth.FinanceMasterKey) {
cfg.Auth.FinanceLegacyMasterKeys = append(cfg.Auth.FinanceLegacyMasterKeys, configuredFinanceMasterKey)
}
if err := cfg.validate(); err != nil { if err := cfg.validate(); err != nil {
return nil, err return nil, err
} }
@@ -75,34 +108,53 @@ func defaultConfig() *Config {
}, },
Redis: RedisConfig{Addr: "127.0.0.1:6379"}, Redis: RedisConfig{Addr: "127.0.0.1:6379"},
Auth: AuthConfig{ Auth: AuthConfig{
SessionKeyPattern: "workbench:session:token:%s", SessionKeyPattern: "olons_server:session:token:%s",
SessionTTLSeconds: 14 * 24 * 60 * 60, SessionTTLSeconds: 14 * 24 * 60 * 60,
DefaultTenantID: 0,
RequiredAPIKey: true, RequiredAPIKey: true,
}, },
Upload: UploadConfig{Driver: "local", Dir: "storage/uploads", PublicPath: "/uploads", Region: "us-east-1"},
IM: IMConfig{
BaseURL: "https://api.118im.com", HTTPAPIURL: "https://api.118im.com/v1/im",
WebSocketURL: "wss://api.118im.com/v0/channels", FileURL: "https://api.118im.com/v0/file",
TenantCode: "default", APIKey: "AQAAAAABAAChdu8_yk0OxZcId6FnwYNS",
InternalService: "olons-server", Timeout: 8 * time.Second,
},
ReadTimeout: 8 * time.Second, ReadTimeout: 8 * time.Second,
WriteTimeout: 8 * time.Second, WriteTimeout: 8 * time.Second,
} }
} }
func applyEnv(cfg *Config) { func applyEnv(cfg *Config) {
setString(&cfg.Listen, "WORKBENCH_LISTEN") setString(&cfg.Listen, "OLONS_SERVER_LISTEN")
setString(&cfg.FrontendDir, "WORKBENCH_FRONTEND_DIR") setString(&cfg.FrontendDir, "OLONS_SERVER_FRONTEND_DIR")
setString(&cfg.MySQL.DSN, "WORKBENCH_MYSQL_DSN") setString(&cfg.MySQL.DSN, "OLONS_SERVER_MYSQL_DSN")
setInt(&cfg.MySQL.MaxOpenConns, "WORKBENCH_MYSQL_MAX_OPEN_CONNS") setInt(&cfg.MySQL.MaxOpenConns, "OLONS_SERVER_MYSQL_MAX_OPEN_CONNS")
setInt(&cfg.MySQL.MaxIdleConns, "WORKBENCH_MYSQL_MAX_IDLE_CONNS") setInt(&cfg.MySQL.MaxIdleConns, "OLONS_SERVER_MYSQL_MAX_IDLE_CONNS")
setString(&cfg.Redis.Addr, "WORKBENCH_REDIS_ADDR") setString(&cfg.Redis.Addr, "OLONS_SERVER_REDIS_ADDR")
setString(&cfg.Redis.Username, "WORKBENCH_REDIS_USERNAME") setString(&cfg.Redis.Username, "OLONS_SERVER_REDIS_USERNAME")
setString(&cfg.Redis.Password, "WORKBENCH_REDIS_PASSWORD") setString(&cfg.Redis.Password, "OLONS_SERVER_REDIS_PASSWORD")
setInt(&cfg.Redis.DB, "WORKBENCH_REDIS_DB") setInt(&cfg.Redis.DB, "OLONS_SERVER_REDIS_DB")
setString(&cfg.Auth.SessionKeyPattern, "WORKBENCH_SESSION_KEY_PATTERN") setString(&cfg.Auth.SessionKeyPattern, "OLONS_SERVER_SESSION_KEY_PATTERN")
setInt(&cfg.Auth.SessionTTLSeconds, "WORKBENCH_SESSION_TTL_SECONDS") setInt(&cfg.Auth.SessionTTLSeconds, "OLONS_SERVER_SESSION_TTL_SECONDS")
setInt64(&cfg.Auth.DefaultTenantID, "WORKBENCH_DEFAULT_TENANT_ID") setString(&cfg.Auth.DataEncryptionKey, "OLONS_SERVER_DATA_ENCRYPTION_KEY")
setString(&cfg.Auth.DataEncryptionKey, "WORKBENCH_DATA_ENCRYPTION_KEY") setString(&cfg.Auth.LookupHMACKey, "OLONS_SERVER_LOOKUP_HMAC_KEY")
setString(&cfg.Auth.LookupHMACKey, "WORKBENCH_LOOKUP_HMAC_KEY") setString(&cfg.Auth.FinanceMasterKey, "OLONS_SERVER_FINANCE_MASTER_KEY")
setBool(&cfg.Auth.AllowDevHeaderAuth, "WORKBENCH_ALLOW_DEV_HEADER_AUTH") if keys := strings.TrimSpace(os.Getenv("OLONS_SERVER_FINANCE_LEGACY_MASTER_KEYS")); keys != "" {
setBool(&cfg.Auth.RequiredAPIKey, "WORKBENCH_REQUIRED_API_KEY") cfg.Auth.FinanceLegacyMasterKeys = append(cfg.Auth.FinanceLegacyMasterKeys, splitCSV(keys)...)
if keys := strings.TrimSpace(os.Getenv("WORKBENCH_API_KEYS")); keys != "" { }
setBool(&cfg.Auth.AllowDevHeaderAuth, "OLONS_SERVER_ALLOW_DEV_HEADER_AUTH")
setBool(&cfg.Auth.RequiredAPIKey, "OLONS_SERVER_REQUIRED_API_KEY")
ApplyUploadEnv(&cfg.Upload)
setString(&cfg.IM.BaseURL, "OLONS_IM_BASE_URL")
setString(&cfg.IM.HTTPAPIURL, "OLONS_IM_HTTP_API_URL")
setString(&cfg.IM.WebSocketURL, "OLONS_IM_WS_URL")
setString(&cfg.IM.FileURL, "OLONS_IM_FILE_URL")
setString(&cfg.IM.TenantCode, "OLONS_IM_TENANT_CODE")
setString(&cfg.IM.APIKey, "OLONS_IM_API_KEY")
setString(&cfg.IM.InternalService, "OLONS_IM_INTERNAL_SERVICE")
setString(&cfg.IM.InternalSecret, "OLONS_IM_INTERNAL_SECRET")
setDuration(&cfg.IM.Timeout, "OLONS_IM_TIMEOUT")
if keys := strings.TrimSpace(os.Getenv("OLONS_SERVER_API_KEYS")); keys != "" {
cfg.Auth.APIKeys = splitCSV(keys) cfg.Auth.APIKeys = splitCSV(keys)
} }
} }
@@ -130,12 +182,73 @@ func (c *Config) validate() error {
if c.Auth.SessionTTLSeconds <= 0 { if c.Auth.SessionTTLSeconds <= 0 {
c.Auth.SessionTTLSeconds = 14 * 24 * 60 * 60 c.Auth.SessionTTLSeconds = 14 * 24 * 60 * 60
} }
if c.Auth.DefaultTenantID < 0 { if err := ValidateUpload(c.Upload); err != nil {
return errors.New("default tenant id must not be negative") return err
}
if c.IM.Timeout <= 0 {
c.IM.Timeout = 8 * time.Second
} }
return nil return nil
} }
func ApplyUploadSystemConfig(cfg *UploadConfig, values map[string]string) {
if cfg == nil {
return
}
setMapString(&cfg.Driver, values, "uploadDrive")
setMapString(&cfg.AccessKey, values, "uploadMinioAccessKey")
setMapString(&cfg.SecretKey, values, "uploadMinioSecretKey")
setMapString(&cfg.Endpoint, values, "uploadMinioEndpoint")
setMapString(&cfg.Path, values, "uploadMinioPath")
setMapString(&cfg.Bucket, values, "uploadMinioBucket")
setMapString(&cfg.Domain, values, "uploadMinioDomain")
if value := strings.TrimSpace(values["uploadMinioUseSSL"]); value != "" {
cfg.Secure = value == "1" || strings.EqualFold(value, "true")
}
}
func ApplyUploadEnv(cfg *UploadConfig) {
if cfg == nil {
return
}
setString(&cfg.Driver, "OLONS_SERVER_UPLOAD_DRIVER")
setString(&cfg.Dir, "OLONS_SERVER_UPLOAD_DIR")
setString(&cfg.PublicPath, "OLONS_SERVER_UPLOAD_PUBLIC_PATH")
setString(&cfg.AccessKey, "OLONS_SERVER_MINIO_ACCESS_KEY")
setString(&cfg.SecretKey, "OLONS_SERVER_MINIO_SECRET_KEY")
setString(&cfg.Endpoint, "OLONS_SERVER_MINIO_ENDPOINT")
setString(&cfg.Path, "OLONS_SERVER_MINIO_PATH")
setString(&cfg.Bucket, "OLONS_SERVER_MINIO_BUCKET")
setString(&cfg.Domain, "OLONS_SERVER_MINIO_DOMAIN")
setString(&cfg.Region, "OLONS_SERVER_MINIO_REGION")
if value := strings.TrimSpace(os.Getenv("OLONS_SERVER_MINIO_SECURE")); value != "" {
cfg.Secure = value == "1" || strings.EqualFold(value, "true")
}
}
func ValidateUpload(cfg UploadConfig) error {
switch strings.ToLower(strings.TrimSpace(cfg.Driver)) {
case "local":
if strings.TrimSpace(cfg.Dir) == "" || !strings.HasPrefix(cfg.PublicPath, "/") {
return errors.New("local upload config is invalid")
}
case "minio":
if strings.TrimSpace(cfg.AccessKey) == "" || strings.TrimSpace(cfg.SecretKey) == "" ||
strings.TrimSpace(cfg.Endpoint) == "" || strings.TrimSpace(cfg.Bucket) == "" || strings.TrimSpace(cfg.Domain) == "" {
return errors.New("minio upload config is incomplete")
}
default:
return errors.New("upload driver is unsupported")
}
return nil
}
func setMapString(target *string, values map[string]string, key string) {
if value := strings.TrimSpace(values[key]); value != "" {
*target = value
}
}
func setString(target *string, key string) { func setString(target *string, key string) {
if value := strings.TrimSpace(os.Getenv(key)); value != "" { if value := strings.TrimSpace(os.Getenv(key)); value != "" {
*target = value *target = value
@@ -150,14 +263,6 @@ func setInt(target *int, key string) {
} }
} }
func setInt64(target *int64, key string) {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
if parsed, err := strconv.ParseInt(value, 10, 64); err == nil {
*target = parsed
}
}
}
func setBool(target *bool, key string) { func setBool(target *bool, key string) {
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) { switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
case "1", "true", "yes", "on": case "1", "true", "yes", "on":
@@ -167,6 +272,14 @@ func setBool(target *bool, key string) {
} }
} }
func setDuration(target *time.Duration, key string) {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
if parsed, err := time.ParseDuration(value); err == nil {
*target = parsed
}
}
}
func splitCSV(value string) []string { func splitCSV(value string) []string {
parts := strings.Split(value, ",") parts := strings.Split(value, ",")
items := make([]string, 0, len(parts)) items := make([]string, 0, len(parts))
+51 -3
View File
@@ -1,14 +1,18 @@
package config package config
import "testing" import (
"os"
"path/filepath"
"testing"
)
func TestDefaultConfigUsesOlonsAdmin(t *testing.T) { func TestDefaultConfigUsesOlonsAdmin(t *testing.T) {
cfg := defaultConfig() cfg := defaultConfig()
if err := cfg.validate(); err != nil { if err := cfg.validate(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if cfg.Auth.DefaultTenantID != 0 { if cfg.IM.TenantCode != "default" {
t.Fatalf("DefaultTenantID = %d", cfg.Auth.DefaultTenantID) t.Fatalf("unexpected default IM tenant: %q", cfg.IM.TenantCode)
} }
} }
@@ -19,3 +23,47 @@ func TestConfigRejectsOtherDatabase(t *testing.T) {
t.Fatal("expected non-olons_admin database to be rejected") t.Fatal("expected non-olons_admin database to be rejected")
} }
} }
func TestLoadPreservesConfiguredFinanceKeyAsLegacyWhenEnvironmentOverridesIt(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
data := []byte(`{"auth":{"finance_master_key":"legacy-finance-master-key-with-at-least-32-characters"}}`)
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("OLONS_SERVER_CONFIG", path)
t.Setenv("OLONS_SERVER_FINANCE_MASTER_KEY", "current-finance-master-key-with-at-least-32-characters")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if len(cfg.Auth.FinanceLegacyMasterKeys) != 1 ||
cfg.Auth.FinanceLegacyMasterKeys[0] != "legacy-finance-master-key-with-at-least-32-characters" {
t.Fatalf("configured finance key was not retained for historical data: %#v", cfg.Auth.FinanceLegacyMasterKeys)
}
}
func TestApplyUploadSystemConfig(t *testing.T) {
cfg := UploadConfig{Driver: "local", Region: "us-east-1"}
ApplyUploadSystemConfig(&cfg, map[string]string{
"uploadDrive": "minio",
"uploadMinioAccessKey": "access",
"uploadMinioSecretKey": "secret",
"uploadMinioEndpoint": "minio.example.com",
"uploadMinioUseSSL": "1",
"uploadMinioPath": "images/",
"uploadMinioBucket": "bucket",
"uploadMinioDomain": "https://cdn.example.com",
})
if err := ValidateUpload(cfg); err != nil {
t.Fatal(err)
}
if cfg.Driver != "minio" || !cfg.Secure || cfg.Bucket != "bucket" || cfg.Path != "images/" {
t.Fatalf("unexpected upload config: %+v", cfg)
}
}
func TestValidateUploadRejectsIncompleteMinio(t *testing.T) {
if err := ValidateUpload(UploadConfig{Driver: "minio"}); err == nil {
t.Fatal("expected incomplete minio config to be rejected")
}
}
+233 -135
View File
@@ -19,8 +19,8 @@ import (
"unicode" "unicode"
"unicode/utf8" "unicode/utf8"
"git.118im.com/im_team/im_workbench/internal/config" "git.118im.com/im_team/olons_server/internal/config"
"git.118im.com/im_team/im_workbench/internal/store" "git.118im.com/im_team/olons_server/internal/store"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -34,9 +34,6 @@ const (
var ( var (
tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`) tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`)
phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`)
bankCodePattern = regexp.MustCompile(`^[A-Z0-9_-]{2,32}$`)
accountTokenPattern = regexp.MustCompile(`^[A-Za-z0-9@._+\-]{5,64}$`)
idempotencyPattern = regexp.MustCompile(`^[A-Za-z0-9._:\-]{8,80}$`) idempotencyPattern = regexp.MustCompile(`^[A-Za-z0-9._:\-]{8,80}$`)
) )
@@ -44,7 +41,9 @@ type Repository interface {
CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error)
AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error)
PaymentAccounts(context.Context, int64, int64) ([]store.PaymentAccount, error) PaymentAccounts(context.Context, int64, int64) ([]store.PaymentAccount, error)
PaymentAccountDetails(context.Context, int64, int64) ([]store.PaymentAccount, error)
CreatePaymentAccount(context.Context, store.PaymentAccountCreation) (*store.PaymentAccount, error) CreatePaymentAccount(context.Context, store.PaymentAccountCreation) (*store.PaymentAccount, error)
UpdatePaymentAccount(context.Context, int64, store.PaymentAccountCreation) (*store.PaymentAccount, error)
PaymentAccountForWithdrawal(context.Context, int64, int64, int64) (*store.PaymentAccount, error) PaymentAccountForWithdrawal(context.Context, int64, int64, int64) (*store.PaymentAccount, error)
ActiveWithdrawalChannel(context.Context, int64, []int) (*store.WithdrawalChannel, error) ActiveWithdrawalChannel(context.Context, int64, []int) (*store.WithdrawalChannel, error)
WithdrawalWallet(context.Context, int64, int64) (*store.WithdrawalWallet, error) WithdrawalWallet(context.Context, int64, int64) (*store.WithdrawalWallet, error)
@@ -59,38 +58,54 @@ type Repository interface {
} }
type Service struct { type Service struct {
repository Repository repository Repository
redis *redis.Client redis *redis.Client
aead cipher.AEAD aead cipher.AEAD
lookupKey []byte legacyAEADs []cipher.AEAD
ready bool lookupKey []byte
now func() time.Time ready bool
now func() time.Time
} }
type PaymentAccountInput struct { type PaymentAccountInput struct {
AccountType string `json:"account_type"` ID int64 `json:"id"`
BankCode string `json:"bank_code"` BankName string `json:"bank_name"`
BankName string `json:"bank_name"` HolderName string `json:"holder_name"`
HolderName string `json:"holder_name"` AccountNo string `json:"account_no"`
AccountNo string `json:"account_no"` DigitalRMBCode string `json:"digital_rmb_code"`
ReservedPhone string `json:"reserved_phone"` WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
QRAttachmentID int64 `json:"qr_attachment_id"` AlipayQRAttachmentID int64 `json:"alipay_qr_attachment_id"`
TradePassword string `json:"trade_password"`
} }
type PaymentAccount struct { type PaymentAccount struct {
ID int64 `json:"id"` ID int64 `json:"id"`
AccountType string `json:"account_type"` AccountType string `json:"account_type"`
BankCode string `json:"bank_code"` BankCode string `json:"bank_code"`
BankName string `json:"bank_name"` BankName string `json:"bank_name"`
HolderMask string `json:"holder_mask"` HolderMask string `json:"holder_mask"`
MobileMask string `json:"mobile_mask"` AccountNoMask string `json:"account_no_mask"`
AccountNoMask string `json:"account_no_mask"` DigitalRMBCodeMask string `json:"digital_rmb_code_mask"`
QRAttachmentID int64 `json:"qr_attachment_id"` WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
VerifyStatus string `json:"verify_status"` AlipayQRAttachmentID int64 `json:"alipay_qr_attachment_id"`
IsDefault bool `json:"is_default"` VerifyStatus string `json:"verify_status"`
CreatedAt string `json:"created_at,omitempty"` IsDefault bool `json:"is_default"`
UpdatedAt string `json:"updated_at,omitempty"` CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type PaymentAccountDetail struct {
ID int64 `json:"id"`
BankName string `json:"bank_name"`
HolderName string `json:"holder_name"`
AccountNo string `json:"account_no"`
DigitalRMBCode string `json:"digital_rmb_code"`
WechatQRURL string `json:"wechat_qr_url"`
AlipayQRURL string `json:"alipay_qr_url"`
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
AlipayQRAttachmentID int64 `json:"alipay_qr_attachment_id"`
VerifyStatus string `json:"verify_status"`
IsDefault bool `json:"is_default"`
Masked bool `json:"masked"`
} }
type QuoteInput struct { type QuoteInput struct {
@@ -235,25 +250,58 @@ type WalletLedgerPage struct {
func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service { func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service {
service := &Service{repository: repository, redis: redisClient, now: time.Now} service := &Service{repository: repository, redis: redisClient, now: time.Now}
encryptionKey, encryptionErr := decodeKey(cfg.DataEncryptionKey) masterKey := strings.TrimSpace(cfg.FinanceMasterKey)
lookupKey, lookupErr := decodeKey(cfg.LookupHMACKey) var encryptionKey, lookupKey []byte
if encryptionErr != nil || len(encryptionKey) != 32 || lookupErr != nil || len(lookupKey) < 32 { if len([]byte(masterKey)) >= 32 {
return service encryptionKey = deriveKey(masterKey, "data-encryption")
lookupKey = deriveKey(masterKey, "lookup-hmac")
} else {
var encryptionErr, lookupErr error
encryptionKey, encryptionErr = decodeKey(cfg.DataEncryptionKey)
lookupKey, lookupErr = decodeKey(cfg.LookupHMACKey)
if encryptionErr != nil || len(encryptionKey) != 32 || lookupErr != nil || len(lookupKey) < 32 {
return service
}
} }
block, err := aes.NewCipher(encryptionKey) aead, err := newAEAD(encryptionKey)
if err != nil {
return service
}
aead, err := cipher.NewGCM(block)
if err != nil { if err != nil {
return service return service
} }
service.aead = aead service.aead = aead
seenLegacyKeys := make(map[string]struct{}, len(cfg.FinanceLegacyMasterKeys))
for _, legacyMasterKey := range cfg.FinanceLegacyMasterKeys {
legacyMasterKey = strings.TrimSpace(legacyMasterKey)
if len([]byte(legacyMasterKey)) < 32 || legacyMasterKey == masterKey {
continue
}
if _, exists := seenLegacyKeys[legacyMasterKey]; exists {
continue
}
legacyAEAD, legacyErr := newAEAD(deriveKey(legacyMasterKey, "data-encryption"))
if legacyErr == nil {
service.legacyAEADs = append(service.legacyAEADs, legacyAEAD)
seenLegacyKeys[legacyMasterKey] = struct{}{}
}
}
service.lookupKey = lookupKey service.lookupKey = lookupKey
service.ready = repository != nil && redisClient != nil service.ready = repository != nil && redisClient != nil
return service return service
} }
func newAEAD(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
func deriveKey(masterKey, purpose string) []byte {
mac := hmac.New(sha256.New, []byte(masterKey))
_, _ = mac.Write([]byte("olons-finance:" + purpose + ":v1"))
return mac.Sum(nil)
}
func (s *Service) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) { func (s *Service) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
if s.repository == nil { if s.repository == nil {
return nil, ErrUnavailable return nil, ErrUnavailable
@@ -269,41 +317,50 @@ func (s *Service) PaymentAccounts(ctx context.Context, tenantID, memberID int64)
return items, nil return items, nil
} }
func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID int64, input PaymentAccountInput) (*PaymentAccount, error) { func (s *Service) PaymentAccountDetails(ctx context.Context, tenantID, memberID int64) ([]PaymentAccountDetail, error) {
if !s.ready { if s.repository == nil {
return nil, ErrUnavailable return nil, ErrUnavailable
} }
accountType, err := normalizePaymentAccount(&input) rows, err := s.repository.PaymentAccountDetails(ctx, tenantID, memberID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
security, err := s.verifyTradePassword(ctx, tenantID, memberID, input.TradePassword) items := make([]PaymentAccountDetail, 0, len(rows))
if err != nil { for _, row := range rows {
return nil, err holderName, accountNo := row.HolderMask, row.AccountNoMask
} digitalRMBCode := row.DigitalRMBCodeMask
holderCipher, err := s.encrypt(input.HolderName) masked := true
if err != nil { if s.ready {
return nil, err decryptedHolder, holderErr := s.decrypt(row.HolderCipher)
} decryptedAccount, accountErr := s.decrypt(row.AccountNoCipher)
accountCipher, err := s.encrypt(input.AccountNo) if holderErr == nil && accountErr == nil {
if err != nil { holderName, accountNo, masked = decryptedHolder, decryptedAccount, false
return nil, err if row.DigitalRMBCodeCipher != "" {
} if value, decryptErr := s.decrypt(row.DigitalRMBCodeCipher); decryptErr == nil {
mobileCipher := "" digitalRMBCode = value
if input.ReservedPhone != "" { } else {
mobileCipher, err = s.encrypt("+86" + input.ReservedPhone) masked = true
if err != nil { }
return nil, err }
}
} }
items = append(items, PaymentAccountDetail{
ID: row.ID, BankName: row.BankName, HolderName: holderName,
AccountNo: accountNo, DigitalRMBCode: digitalRMBCode,
WechatQRURL: row.WechatQRURL, AlipayQRURL: row.AlipayQRURL,
WechatQRAttachmentID: row.WechatQRAttachmentID, AlipayQRAttachmentID: row.AlipayQRAttachmentID,
VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1, Masked: masked,
})
} }
row, err := s.repository.CreatePaymentAccount(ctx, store.PaymentAccountCreation{ return items, nil
TenantID: tenantID, MemberID: memberID, ExpectedTradePasswordHash: security.TradePasswordHash, }
AccountType: accountType, BankCode: input.BankCode, BankName: input.BankName,
HolderCipher: holderCipher, HolderMask: maskName(input.HolderName), MobileCipher: mobileCipher, func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID int64, input PaymentAccountInput) (*PaymentAccount, error) {
MobileMask: maskPhone(input.ReservedPhone), AccountNoCipher: accountCipher, data, err := s.paymentAccountCreation(tenantID, memberID, input)
AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo), if err != nil {
QRAttachmentID: input.QRAttachmentID, return nil, err
}) }
row, err := s.repository.CreatePaymentAccount(ctx, data)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -311,6 +368,58 @@ func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID i
return &result, nil return &result, nil
} }
func (s *Service) UpdatePaymentAccount(ctx context.Context, tenantID, memberID int64, input PaymentAccountInput) (*PaymentAccount, error) {
if input.ID <= 0 {
return nil, ErrAccountFieldsInvalid
}
data, err := s.paymentAccountCreation(tenantID, memberID, input)
if err != nil {
return nil, err
}
row, err := s.repository.UpdatePaymentAccount(ctx, input.ID, data)
if err != nil {
return nil, err
}
result := paymentAccountFromStore(*row)
return &result, nil
}
func (s *Service) paymentAccountCreation(tenantID, memberID int64, input PaymentAccountInput) (store.PaymentAccountCreation, error) {
if !s.ready {
return store.PaymentAccountCreation{}, ErrUnavailable
}
if err := normalizePaymentAccount(&input); err != nil {
return store.PaymentAccountCreation{}, err
}
holderCipher, err := s.encrypt(input.HolderName)
if err != nil {
return store.PaymentAccountCreation{}, err
}
accountCipher, err := s.encrypt(input.AccountNo)
if err != nil {
return store.PaymentAccountCreation{}, err
}
digitalRMBCodeCipher, digitalRMBCodeHash, digitalRMBCodeMask := "", "", ""
if input.DigitalRMBCode != "" {
digitalRMBCodeCipher, err = s.encrypt(input.DigitalRMBCode)
if err != nil {
return store.PaymentAccountCreation{}, err
}
digitalRMBCodeHash = s.lookupHash(input.DigitalRMBCode)
digitalRMBCodeMask = maskAccount(input.DigitalRMBCode)
}
return store.PaymentAccountCreation{
TenantID: tenantID, MemberID: memberID, AccountType: 1, BankCode: "BANK", BankName: input.BankName,
HolderCipher: holderCipher, HolderMask: maskName(input.HolderName),
AccountNoCipher: accountCipher,
AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo),
DigitalRMBCodeCipher: digitalRMBCodeCipher, DigitalRMBCodeHash: digitalRMBCodeHash,
DigitalRMBCodeMask: digitalRMBCodeMask,
WechatQRAttachmentID: input.WechatQRAttachmentID,
AlipayQRAttachmentID: input.AlipayQRAttachmentID,
}, nil
}
func (s *Service) Quote(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, error) { func (s *Service) Quote(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, error) {
if !s.ready { if !s.ready {
return nil, ErrUnavailable return nil, ErrUnavailable
@@ -349,9 +458,9 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
if err != nil { if err != nil {
return nil, store.ErrPaymentAccountInvalid return nil, store.ErrPaymentAccountInvalid
} }
reservedPhone := "" digitalRMBCode := ""
if account.MobileCipher != "" { if account.DigitalRMBCodeCipher != "" {
reservedPhone, err = s.decrypt(account.MobileCipher) digitalRMBCode, err = s.decrypt(account.DigitalRMBCodeCipher)
if err != nil { if err != nil {
return nil, store.ErrPaymentAccountInvalid return nil, store.ErrPaymentAccountInvalid
} }
@@ -359,8 +468,10 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
snapshot, err := json.Marshal(map[string]any{ snapshot, err := json.Marshal(map[string]any{
"account_type": accountTypeName(account.AccountType), "bank_code": account.BankCode, "account_type": accountTypeName(account.AccountType), "bank_code": account.BankCode,
"bank_name": account.BankName, "holder_name": holderName, "bank_name": account.BankName, "holder_name": holderName,
"reserved_phone": reservedPhone, "account_no": accountNo, "account_no": accountNo,
"qr_attachment_id": account.QRAttachmentID, "digital_rmb_code": digitalRMBCode,
"wechat_qr_attachment_id": account.WechatQRAttachmentID,
"alipay_qr_attachment_id": account.AlipayQRAttachmentID,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -375,10 +486,14 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
WithdrawalNo: withdrawalNo, PaymentAccountID: account.ID, WithdrawalNo: withdrawalNo, PaymentAccountID: account.ID,
ExpectedAccountType: account.AccountType, ExpectedBankCode: account.BankCode, ExpectedAccountType: account.AccountType, ExpectedBankCode: account.BankCode,
ExpectedBankName: account.BankName, ExpectedHolderCipher: account.HolderCipher, ExpectedBankName: account.BankName, ExpectedHolderCipher: account.HolderCipher,
ExpectedMobileCipher: account.MobileCipher, ExpectedAccountNoCipher: account.AccountNoCipher, ExpectedIdentityNoCipher: account.IdentityNoCipher, ExpectedIdentityNoHash: account.IdentityNoHash,
ExpectedAccountNoHash: account.AccountNoHash, ExpectedQRAttachmentID: account.QRAttachmentID, ExpectedAccountNoCipher: account.AccountNoCipher, ExpectedAccountNoHash: account.AccountNoHash,
Channel: *channel, ExpectedDigitalRMBCodeCipher: account.DigitalRMBCodeCipher,
RequestAmount: quote.RequestAmount, FeeAmount: quote.FeeAmount, ActualAmount: quote.ActualAmount, ExpectedDigitalRMBCodeHash: account.DigitalRMBCodeHash,
ExpectedWechatQRAttachmentID: account.WechatQRAttachmentID,
ExpectedAlipayQRAttachmentID: account.AlipayQRAttachmentID,
Channel: *channel,
RequestAmount: quote.RequestAmount, FeeAmount: quote.FeeAmount, ActualAmount: quote.ActualAmount,
Currency: quote.Currency, AccountSnapshotCipher: snapshotCipher, AccountMask: account.AccountNoMask, Currency: quote.Currency, AccountSnapshotCipher: snapshotCipher, AccountMask: account.AccountNoMask,
BankName: account.BankName, HolderMask: account.HolderMask, BankName: account.BankName, HolderMask: account.HolderMask,
AvailableLedgerNo: deterministicNumber("LA", tenantID, memberID, idempotencyKey), AvailableLedgerNo: deterministicNumber("LA", tenantID, memberID, idempotencyKey),
@@ -685,7 +800,10 @@ func (s *Service) ensureWithdrawalEnabled(ctx context.Context, tenantID int64) e
return err return err
} }
var config map[string]any var config map[string]any
if json.Unmarshal(raw, &config) != nil || !findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL") { if json.Unmarshal(raw, &config) != nil {
config = map[string]any{}
}
if enabled, found := findBoolValue(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"); found && !enabled {
return store.ErrWithdrawalDisabled return store.ErrWithdrawalDisabled
} }
return nil return nil
@@ -695,7 +813,7 @@ func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID in
if !tradePasswordPattern.MatchString(strings.TrimSpace(password)) { if !tradePasswordPattern.MatchString(strings.TrimSpace(password)) {
return nil, ErrTradePassword return nil, ErrTradePassword
} }
key := fmt.Sprintf("workbench:finance:trade-password-rate:%d:%d", tenantID, memberID) key := fmt.Sprintf("olons_server:finance:trade-password-rate:%d:%d", tenantID, memberID)
attempts, err := s.redis.Get(ctx, key).Int() attempts, err := s.redis.Get(ctx, key).Int()
if err != nil && !errors.Is(err, redis.Nil) { if err != nil && !errors.Is(err, redis.Nil) {
return nil, ErrUnavailable return nil, ErrUnavailable
@@ -725,58 +843,29 @@ func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID in
return security, nil return security, nil
} }
func normalizePaymentAccount(input *PaymentAccountInput) (int, error) { func normalizePaymentAccount(input *PaymentAccountInput) error {
input.AccountType = strings.ToLower(strings.TrimSpace(input.AccountType))
input.BankCode = strings.ToUpper(strings.TrimSpace(input.BankCode))
input.BankName = strings.TrimSpace(input.BankName) input.BankName = strings.TrimSpace(input.BankName)
input.HolderName = strings.TrimSpace(input.HolderName) input.HolderName = strings.TrimSpace(input.HolderName)
input.AccountNo = strings.TrimSpace(input.AccountNo) input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.AccountNo))
input.ReservedPhone = strings.TrimSpace(input.ReservedPhone) input.DigitalRMBCode = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.DigitalRMBCode))
if !validHumanName(input.HolderName) || (input.ReservedPhone != "" && !phonePattern.MatchString(input.ReservedPhone)) || input.QRAttachmentID < 0 { if !validHumanName(input.HolderName) ||
return 0, ErrAccountFieldsInvalid utf8.RuneCountInString(input.BankName) < 2 || utf8.RuneCountInString(input.BankName) > 100 ||
} len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) ||
switch input.AccountType { (input.DigitalRMBCode != "" && (len(input.DigitalRMBCode) < 6 || len(input.DigitalRMBCode) > 32 || !allDigits(input.DigitalRMBCode))) ||
case "bank_card": input.WechatQRAttachmentID <= 0 || input.AlipayQRAttachmentID <= 0 ||
input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(input.AccountNo) input.WechatQRAttachmentID == input.AlipayQRAttachmentID {
if !bankCodePattern.MatchString(input.BankCode) || utf8.RuneCountInString(input.BankName) < 2 || return ErrAccountFieldsInvalid
utf8.RuneCountInString(input.BankName) > 100 || len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) {
return 0, ErrAccountFieldsInvalid
}
return 1, nil
case "digital_wallet":
if !accountTokenPattern.MatchString(input.AccountNo) {
return 0, ErrAccountFieldsInvalid
}
return 2, nil
case "unionpay":
if !accountTokenPattern.MatchString(input.AccountNo) {
return 0, ErrAccountFieldsInvalid
}
return 3, nil
case "alipay":
input.BankName = "支付宝"
input.BankCode = "ALIPAY"
if !accountTokenPattern.MatchString(input.AccountNo) {
return 0, ErrAccountFieldsInvalid
}
return 4, nil
case "wechat":
input.BankName = "微信"
input.BankCode = "WECHAT"
if !accountTokenPattern.MatchString(input.AccountNo) {
return 0, ErrAccountFieldsInvalid
}
return 5, nil
default:
return 0, ErrAccountTypeInvalid
} }
return nil
} }
func paymentAccountFromStore(row store.PaymentAccount) PaymentAccount { func paymentAccountFromStore(row store.PaymentAccount) PaymentAccount {
return PaymentAccount{ID: row.ID, AccountType: accountTypeName(row.AccountType), BankCode: row.BankCode, return PaymentAccount{ID: row.ID, AccountType: accountTypeName(row.AccountType), BankCode: row.BankCode,
BankName: row.BankName, HolderMask: row.HolderMask, MobileMask: row.MobileMask, BankName: row.BankName, HolderMask: row.HolderMask,
AccountNoMask: row.AccountNoMask, QRAttachmentID: row.QRAttachmentID, AccountNoMask: row.AccountNoMask, DigitalRMBCodeMask: row.DigitalRMBCodeMask,
VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1, WechatQRAttachmentID: row.WechatQRAttachmentID,
AlipayQRAttachmentID: row.AlipayQRAttachmentID,
VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1,
CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt} CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
} }
@@ -949,11 +1038,17 @@ func (s *Service) decrypt(value string) (string, error) {
if err != nil || len(payload) <= s.aead.NonceSize() { if err != nil || len(payload) <= s.aead.NonceSize() {
return "", errors.New("encrypted value is invalid") return "", errors.New("encrypted value is invalid")
} }
plain, err := s.aead.Open(nil, payload[:s.aead.NonceSize()], payload[s.aead.NonceSize():], nil) aeads := append([]cipher.AEAD{s.aead}, s.legacyAEADs...)
if err != nil { for _, aead := range aeads {
return "", err if len(payload) <= aead.NonceSize() {
continue
}
plain, decryptErr := aead.Open(nil, payload[:aead.NonceSize()], payload[aead.NonceSize():], nil)
if decryptErr == nil {
return string(plain), nil
}
} }
return string(plain), nil return "", errors.New("encrypted value cannot be decrypted")
} }
func (s *Service) lookupHash(value string) string { func (s *Service) lookupHash(value string) string {
@@ -1045,25 +1140,28 @@ func allDigits(value string) bool {
return value != "" return value != ""
} }
func findBool(config map[string]any, keys ...string) bool { func findBoolValue(config map[string]any, keys ...string) (bool, bool) {
for _, key := range keys { for _, key := range keys {
for candidate, value := range config { for candidate, value := range config {
if strings.EqualFold(candidate, key) { if strings.EqualFold(candidate, key) {
switch typed := value.(type) { switch typed := value.(type) {
case bool: case bool:
return typed return typed, true
case float64: case float64:
return typed == 1 return typed == 1, true
case string: case string:
return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on") return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on"), true
} }
return false, true
} }
} }
} }
for _, value := range config { for _, value := range config {
if nested, ok := value.(map[string]any); ok && findBool(nested, keys...) { if nested, ok := value.(map[string]any); ok {
return true if result, found := findBoolValue(nested, keys...); found {
return result, true
}
} }
} }
return false return false, false
} }
+187 -12
View File
@@ -7,13 +7,15 @@ import (
"testing" "testing"
"time" "time"
"git.118im.com/im_team/im_workbench/internal/config" "git.118im.com/im_team/olons_server/internal/config"
"git.118im.com/im_team/im_workbench/internal/store" "git.118im.com/im_team/olons_server/internal/store"
"github.com/redis/go-redis/v9"
) )
type fakeRepository struct { type fakeRepository struct {
config json.RawMessage config json.RawMessage
account *store.PaymentAccount account *store.PaymentAccount
details []store.PaymentAccount
channel *store.WithdrawalChannel channel *store.WithdrawalChannel
wallet *store.WithdrawalWallet wallet *store.WithdrawalWallet
orders []store.WithdrawalOrder orders []store.WithdrawalOrder
@@ -23,6 +25,24 @@ type fakeRepository struct {
createdRecharge store.RechargeCreation createdRecharge store.RechargeCreation
} }
type paymentAccountRepository struct {
fakeRepository
created store.PaymentAccountCreation
updated store.PaymentAccountCreation
updatedID int64
}
func (f *paymentAccountRepository) CreatePaymentAccount(_ context.Context, input store.PaymentAccountCreation) (*store.PaymentAccount, error) {
f.created = input
return &store.PaymentAccount{ID: 1, AccountType: input.AccountType}, nil
}
func (f *paymentAccountRepository) UpdatePaymentAccount(_ context.Context, id int64, input store.PaymentAccountCreation) (*store.PaymentAccount, error) {
f.updatedID = id
f.updated = input
return &store.PaymentAccount{ID: id, AccountType: input.AccountType, BankName: input.BankName}, nil
}
func (f *fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) { func (f *fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) {
return f.config, nil return f.config, nil
} }
@@ -32,9 +52,15 @@ func (f *fakeRepository) AccountSecurity(context.Context, int64, int64) (*store.
func (f *fakeRepository) PaymentAccounts(context.Context, int64, int64) ([]store.PaymentAccount, error) { func (f *fakeRepository) PaymentAccounts(context.Context, int64, int64) ([]store.PaymentAccount, error) {
return nil, nil return nil, nil
} }
func (f *fakeRepository) PaymentAccountDetails(context.Context, int64, int64) ([]store.PaymentAccount, error) {
return f.details, nil
}
func (f *fakeRepository) CreatePaymentAccount(context.Context, store.PaymentAccountCreation) (*store.PaymentAccount, error) { func (f *fakeRepository) CreatePaymentAccount(context.Context, store.PaymentAccountCreation) (*store.PaymentAccount, error) {
return nil, errors.New("not implemented") return nil, errors.New("not implemented")
} }
func (f *fakeRepository) UpdatePaymentAccount(context.Context, int64, store.PaymentAccountCreation) (*store.PaymentAccount, error) {
return nil, errors.New("not implemented")
}
func (f *fakeRepository) PaymentAccountForWithdrawal(context.Context, int64, int64, int64) (*store.PaymentAccount, error) { func (f *fakeRepository) PaymentAccountForWithdrawal(context.Context, int64, int64, int64) (*store.PaymentAccount, error) {
return f.account, nil return f.account, nil
} }
@@ -60,7 +86,7 @@ func (f *fakeRepository) CreateRecharge(_ context.Context, input store.RechargeC
f.createdRecharge = input f.createdRecharge = input
return &store.RechargeOrder{ID: 8, RechargeNo: input.RechargeNo, ChannelID: input.ChannelID, return &store.RechargeOrder{ID: 8, RechargeNo: input.RechargeNo, ChannelID: input.ChannelID,
ChannelName: "人工审核", RequestAmount: input.RequestAmount, Currency: input.Currency, ChannelName: "人工审核", RequestAmount: input.RequestAmount, Currency: input.Currency,
Status: 1, Remark: input.Remark}, nil Status: 2, Remark: input.Remark}, nil
} }
func (f *fakeRepository) RechargeOrders(context.Context, int64, int64, int, int, int) (*store.RechargePage, error) { func (f *fakeRepository) RechargeOrders(context.Context, int64, int64, int, int, int) (*store.RechargePage, error) {
return &store.RechargePage{Items: f.rechargeOrders, Total: int64(len(f.rechargeOrders))}, nil return &store.RechargePage{Items: f.rechargeOrders, Total: int64(len(f.rechargeOrders))}, nil
@@ -92,24 +118,160 @@ func TestQuoteCalculatesRateFeeAndLimits(t *testing.T) {
} }
} }
func TestQuoteFailsClosed(t *testing.T) { func TestQuoteDefaultsToEnabledWithoutWithdrawalSwitch(t *testing.T) {
service := &Service{repository: &fakeRepository{config: json.RawMessage(`{}`)}, ready: true, now: time.Now} service := &Service{repository: &fakeRepository{config: json.RawMessage(`{}`)}, ready: true, now: time.Now}
if err := service.ensureWithdrawalEnabled(context.Background(), 0); err != nil {
t.Fatalf("missing switch should default to enabled, got %v", err)
}
}
func TestQuoteHonorsExplicitDisabledWithdrawalSwitch(t *testing.T) {
service := &Service{repository: &fakeRepository{config: json.RawMessage(`{"features":{"withdrawal_enabled":false}}`)}, ready: true, now: time.Now}
_, err := service.Quote(context.Background(), 0, 1, QuoteInput{PaymentAccountID: 1, Amount: 100, Currency: "CNY"}) _, err := service.Quote(context.Background(), 0, 1, QuoteInput{PaymentAccountID: 1, Amount: 100, Currency: "CNY"})
if !errors.Is(err, store.ErrWithdrawalDisabled) { if !errors.Is(err, store.ErrWithdrawalDisabled) {
t.Fatalf("expected disabled error, got %v", err) t.Fatalf("expected explicitly disabled withdrawal, got %v", err)
} }
} }
func TestNormalizePaymentAccount(t *testing.T) { func TestNormalizePaymentAccount(t *testing.T) {
input := PaymentAccountInput{AccountType: "bank_card", BankCode: "icbc", BankName: "中国工商银行", input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三",
HolderName: "张三", AccountNo: "6222 0000-0000 1234", ReservedPhone: "13800138000"} AccountNo: "6222 0000-0000 1234",
accountType, err := normalizePaymentAccount(&input) DigitalRMBCode: "1234 5678",
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9}
if err := normalizePaymentAccount(&input); err != nil {
t.Fatal(err)
}
if input.AccountNo != "6222000000001234" || input.DigitalRMBCode != "12345678" ||
maskAccount(input.AccountNo) != "6222********1234" {
t.Fatalf("unexpected normalized input: %#v", input)
}
}
func TestNormalizePaymentAccountAllowsEmptyDigitalRMBCode(t *testing.T) {
input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三",
AccountNo: "6222000000001234",
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9}
if err := normalizePaymentAccount(&input); err != nil {
t.Fatalf("empty digital RMB code should be optional: %v", err)
}
input.DigitalRMBCode = "1234"
if err := normalizePaymentAccount(&input); !errors.Is(err, ErrAccountFieldsInvalid) {
t.Fatalf("short non-empty digital RMB code should be rejected: %v", err)
}
}
func TestPaymentAccountDetailsReturnsOriginalFields(t *testing.T) {
repository := &fakeRepository{}
service := New(repository, &redis.Client{}, config.AuthConfig{
FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters",
})
holder, _ := service.encrypt("张三")
account, _ := service.encrypt("6222000000001234")
digital, _ := service.encrypt("123456")
repository.details = []store.PaymentAccount{{
ID: 7, BankName: "中国工商银行", HolderCipher: holder,
AccountNoCipher: account, DigitalRMBCodeCipher: digital,
WechatQRURL: "/wechat.png", AlipayQRURL: "/alipay.png",
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9, VerifyStatus: 2,
}}
items, err := service.PaymentAccountDetails(context.Background(), 1, 2)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if accountType != 1 || input.BankCode != "ICBC" || input.AccountNo != "6222000000001234" || if len(items) != 1 || items[0].HolderName != "张三" ||
maskAccount(input.AccountNo) != "6222********1234" { items[0].AccountNo != "6222000000001234" || items[0].DigitalRMBCode != "123456" ||
t.Fatalf("unexpected normalized input: %#v", input) items[0].WechatQRURL != "/wechat.png" || items[0].AlipayQRURL != "/alipay.png" ||
items[0].WechatQRAttachmentID != 8 || items[0].AlipayQRAttachmentID != 9 {
t.Fatalf("unexpected account details: %#v", items)
}
}
func TestPaymentAccountDetailsDecryptsWithLegacyMasterKey(t *testing.T) {
repository := &fakeRepository{}
legacyMasterKey := "legacy-finance-master-key-with-at-least-32-characters"
legacyService := New(repository, &redis.Client{}, config.AuthConfig{FinanceMasterKey: legacyMasterKey})
holder, _ := legacyService.encrypt("张三")
account, _ := legacyService.encrypt("6222000000001234")
digital, _ := legacyService.encrypt("123456")
repository.details = []store.PaymentAccount{{
ID: 7, BankName: "中国工商银行", HolderCipher: holder, AccountNoCipher: account,
DigitalRMBCodeCipher: digital, WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9,
}}
service := New(repository, &redis.Client{}, config.AuthConfig{
FinanceMasterKey: "current-finance-master-key-with-at-least-32-characters",
FinanceLegacyMasterKeys: []string{legacyMasterKey},
})
items, err := service.PaymentAccountDetails(context.Background(), 1, 2)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].Masked || items[0].HolderName != "张三" ||
items[0].AccountNo != "6222000000001234" || items[0].DigitalRMBCode != "123456" {
t.Fatalf("unexpected legacy account details: %#v", items)
}
}
func TestUpdatePaymentAccountEncryptsEditedFields(t *testing.T) {
repository := &paymentAccountRepository{}
service := New(repository, &redis.Client{}, config.AuthConfig{
FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters",
})
result, err := service.UpdatePaymentAccount(context.Background(), 1, 2, PaymentAccountInput{
ID: 7, BankName: "中国工商银行", HolderName: "李四",
AccountNo: "6222000000001234", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9,
})
if err != nil {
t.Fatal(err)
}
if result.ID != 7 || repository.updatedID != 7 || repository.updated.TenantID != 1 || repository.updated.MemberID != 2 ||
repository.updated.WechatQRAttachmentID != 8 || repository.updated.AlipayQRAttachmentID != 9 ||
repository.updated.DigitalRMBCodeCipher != "" {
t.Fatalf("unexpected updated account: result=%#v data=%#v", result, repository.updated)
}
if holder, err := service.decrypt(repository.updated.HolderCipher); err != nil || holder != "李四" {
t.Fatalf("edited holder was not encrypted correctly: %q, %v", holder, err)
}
}
func TestPaymentAccountDetailsFallsBackToMasksWhenHistoricalKeyIsUnavailable(t *testing.T) {
repository := &fakeRepository{details: []store.PaymentAccount{{
ID: 8, BankName: "中国银行", HolderCipher: "legacy-holder", HolderMask: "张*",
IdentityNoCipher: "legacy-id", IdentityNoMask: "1101**********123X",
AccountNoCipher: "legacy-account", AccountNoMask: "6222********1234",
DigitalRMBCodeCipher: "legacy-rmb", DigitalRMBCodeMask: "12****78",
WechatQRURL: "/wechat.png", AlipayQRURL: "/alipay.png", VerifyStatus: 2,
}}}
service := New(repository, &redis.Client{}, config.AuthConfig{
FinanceMasterKey: "different-finance-master-key-with-32-characters",
})
items, err := service.PaymentAccountDetails(context.Background(), 1, 2)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || !items[0].Masked || items[0].HolderName != "张*" ||
items[0].AccountNo != "6222********1234" ||
items[0].DigitalRMBCode != "12****78" {
t.Fatalf("unexpected masked fallback: %#v", items)
}
}
func TestCreatePaymentAccountDoesNotStoreEmptyDigitalRMBCode(t *testing.T) {
repository := &paymentAccountRepository{}
service := New(repository, &redis.Client{}, config.AuthConfig{
FinanceMasterKey: "test-finance-master-key-with-at-least-32-characters",
})
_, err := service.CreatePaymentAccount(context.Background(), 0, 1, PaymentAccountInput{
BankName: "中国工商银行", HolderName: "张三",
AccountNo: "6222000000001234", WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9,
})
if err != nil {
t.Fatal(err)
}
if repository.created.DigitalRMBCodeCipher != "" || repository.created.DigitalRMBCodeHash != "" || repository.created.DigitalRMBCodeMask != "" {
t.Fatalf("empty digital RMB code must not be encrypted, hashed or masked: %#v", repository.created)
}
if repository.created.IdentityNoCipher != "" || repository.created.IdentityNoHash != "" || repository.created.IdentityNoMask != "" {
t.Fatalf("identity number must no longer be collected: %#v", repository.created)
} }
} }
@@ -138,7 +300,7 @@ func TestCreateRechargeValidatesChannelAndMapsOrder(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if result.Status != "pending_payment" || result.RequestAmount != 50000 || if result.Status != "reviewing" || result.RequestAmount != 50000 ||
repository.createdRecharge.MemberID != 9 || repository.createdRecharge.Currency != "CNY" { repository.createdRecharge.MemberID != 9 || repository.createdRecharge.Currency != "CNY" {
t.Fatalf("unexpected recharge: %#v, creation: %#v", result, repository.createdRecharge) t.Fatalf("unexpected recharge: %#v, creation: %#v", result, repository.createdRecharge)
} }
@@ -219,3 +381,16 @@ func TestEncryptionRoundTrip(t *testing.T) {
t.Fatalf("unexpected round trip: %q, %v", plain, err) t.Fatalf("unexpected round trip: %q, %v", plain, err)
} }
} }
func TestMasterKeyDerivesIndependentFinanceKeys(t *testing.T) {
service := New(&fakeRepository{}, nil, config.AuthConfig{
FinanceMasterKey: "production-master-key-with-at-least-32-characters",
})
if service.aead == nil || len(service.lookupKey) != 32 {
t.Fatal("expected the finance keys to be derived from the master key")
}
if string(deriveKey("production-master-key-with-at-least-32-characters", "data-encryption")) ==
string(deriveKey("production-master-key-with-at-least-32-characters", "lookup-hmac")) {
t.Fatal("derived keys must be isolated by purpose")
}
}
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"net/http" "net/http"
"strings" "strings"
accountsvc "git.118im.com/im_team/im_workbench/internal/accounts" accountsvc "git.118im.com/im_team/olons_server/internal/accounts"
"git.118im.com/im_team/im_workbench/internal/auth" "git.118im.com/im_team/olons_server/internal/auth"
"git.118im.com/im_team/im_workbench/internal/store" "git.118im.com/im_team/olons_server/internal/store"
) )
type accountRegisterRequest struct { type accountRegisterRequest struct {
+25 -15
View File
@@ -3,25 +3,29 @@ package httpapi
import ( import (
"net/http" "net/http"
"git.118im.com/im_team/im_workbench/internal/accounts" "git.118im.com/im_team/olons_server/internal/accounts"
"git.118im.com/im_team/im_workbench/internal/auth" "git.118im.com/im_team/olons_server/internal/attachments"
"git.118im.com/im_team/im_workbench/internal/finance" "git.118im.com/im_team/olons_server/internal/auth"
"git.118im.com/im_team/im_workbench/internal/mall" "git.118im.com/im_team/olons_server/internal/finance"
"git.118im.com/im_team/im_workbench/internal/portal" "git.118im.com/im_team/olons_server/internal/mall"
"git.118im.com/im_team/im_workbench/internal/tasks" "git.118im.com/im_team/olons_server/internal/portal"
"git.118im.com/im_team/olons_server/internal/support"
"git.118im.com/im_team/olons_server/internal/tasks"
) )
type API struct { type API struct {
auth *auth.Authenticator auth *auth.Authenticator
accounts *accounts.Service accounts *accounts.Service
portal *portal.Service portal *portal.Service
tasks *tasks.Service tasks *tasks.Service
mall *mall.Service mall *mall.Service
finance *finance.Service finance *finance.Service
attachments *attachments.Service
support *support.Service
} }
func New(authenticator *auth.Authenticator, accountService *accounts.Service, portalService *portal.Service, taskService *tasks.Service, mallService *mall.Service, financeService *finance.Service) *API { func New(authenticator *auth.Authenticator, accountService *accounts.Service, portalService *portal.Service, taskService *tasks.Service, mallService *mall.Service, financeService *finance.Service, attachmentService *attachments.Service, supportService *support.Service) *API {
return &API{auth: authenticator, accounts: accountService, portal: portalService, tasks: taskService, mall: mallService, finance: financeService} return &API{auth: authenticator, accounts: accountService, portal: portalService, tasks: taskService, mall: mallService, finance: financeService, attachments: attachmentService, support: supportService}
} }
func (a *API) Register(mux *http.ServeMux) { func (a *API) Register(mux *http.ServeMux) {
@@ -38,7 +42,11 @@ func (a *API) Register(mux *http.ServeMux) {
mux.HandleFunc("/v1/member-levels", a.memberLevels) mux.HandleFunc("/v1/member-levels", a.memberLevels)
mux.HandleFunc("/v1/leaderboard", a.leaderboard) mux.HandleFunc("/v1/leaderboard", a.leaderboard)
mux.HandleFunc("/v1/customer-services", a.customerServices) mux.HandleFunc("/v1/customer-services", a.customerServices)
mux.HandleFunc("/v1/customer-services/", a.customerServiceResource)
mux.HandleFunc("/v1/notices", a.notices) mux.HandleFunc("/v1/notices", a.notices)
mux.HandleFunc("/v1/notices/popup", a.popupNotice)
mux.HandleFunc("/v1/notices/pending", a.pendingNotice)
mux.HandleFunc("/v1/notices/", a.noticeResource)
mux.HandleFunc("/v1/task-halls", a.taskHalls) mux.HandleFunc("/v1/task-halls", a.taskHalls)
mux.HandleFunc("/v1/tasks/eligibility", a.taskEligibility) mux.HandleFunc("/v1/tasks/eligibility", a.taskEligibility)
mux.HandleFunc("/v1/tasks/stats", a.taskStats) mux.HandleFunc("/v1/tasks/stats", a.taskStats)
@@ -48,6 +56,8 @@ func (a *API) Register(mux *http.ServeMux) {
mux.HandleFunc("/v1/manual-products", a.manualProducts) mux.HandleFunc("/v1/manual-products", a.manualProducts)
mux.HandleFunc("/v1/manual-products/", a.manualProduct) mux.HandleFunc("/v1/manual-products/", a.manualProduct)
mux.HandleFunc("/v1/payment-accounts", a.paymentAccounts) mux.HandleFunc("/v1/payment-accounts", a.paymentAccounts)
mux.HandleFunc("/v1/payment-accounts/details", a.paymentAccountDetails)
mux.HandleFunc("/v1/attachments/images", a.uploadImage)
mux.HandleFunc("/v1/wallet/ledgers", a.walletLedgers) mux.HandleFunc("/v1/wallet/ledgers", a.walletLedgers)
mux.HandleFunc("/v1/recharge/channels", a.rechargeChannels) mux.HandleFunc("/v1/recharge/channels", a.rechargeChannels)
mux.HandleFunc("/v1/recharge-orders", a.rechargeOrders) mux.HandleFunc("/v1/recharge-orders", a.rechargeOrders)
@@ -58,5 +68,5 @@ func (a *API) Register(mux *http.ServeMux) {
} }
func (a *API) Healthz(w http.ResponseWriter, r *http.Request) { func (a *API) Healthz(w http.ResponseWriter, r *http.Request) {
writeSuccess(w, r, map[string]any{"status": "ok", "service": "workbench"}) writeSuccess(w, r, map[string]any{"status": "ok", "service": "olons_server"})
} }
+36
View File
@@ -0,0 +1,36 @@
package httpapi
import (
"errors"
"net/http"
"git.118im.com/im_team/olons_server/internal/attachments"
)
func (a *API) uploadImage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w, r, http.MethodPost)
return
}
principal, ok := a.authenticateMember(w, r)
if !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, 6<<20)
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, r, http.StatusBadRequest, 30000, "IMAGE_INVALID", "validation failed", map[string]any{"field": "file"})
return
}
defer file.Close()
result, err := a.attachments.UploadImage(r.Context(), principal.MemberID, header.Filename, file)
if err != nil {
if errors.Is(err, attachments.ErrImageInvalid) {
writeError(w, r, http.StatusBadRequest, 30000, "IMAGE_INVALID", "validation failed", map[string]any{"field": "file"})
} else {
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
}
return
}
writeSuccess(w, r, result)
}
+30 -6
View File
@@ -6,14 +6,14 @@ import (
"strconv" "strconv"
"strings" "strings"
"git.118im.com/im_team/im_workbench/internal/auth" "git.118im.com/im_team/olons_server/internal/auth"
"git.118im.com/im_team/im_workbench/internal/finance" "git.118im.com/im_team/olons_server/internal/finance"
"git.118im.com/im_team/im_workbench/internal/store" "git.118im.com/im_team/olons_server/internal/store"
) )
func (a *API) paymentAccounts(w http.ResponseWriter, r *http.Request) { func (a *API) paymentAccounts(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost { if r.Method != http.MethodGet && r.Method != http.MethodPost && r.Method != http.MethodPut {
methodNotAllowed(w, r, http.MethodGet, http.MethodPost) methodNotAllowed(w, r, http.MethodGet, http.MethodPost, http.MethodPut)
return return
} }
principal, ok := a.authenticateFinanceMember(w, r) principal, ok := a.authenticateFinanceMember(w, r)
@@ -33,7 +33,31 @@ func (a *API) paymentAccounts(w http.ResponseWriter, r *http.Request) {
if !decodeBody(w, r, &body) { if !decodeBody(w, r, &body) {
return return
} }
result, err := a.finance.CreatePaymentAccount(r.Context(), principal.TenantID, principal.MemberID, body) var result *finance.PaymentAccount
var err error
if r.Method == http.MethodPut {
result, err = a.finance.UpdatePaymentAccount(r.Context(), principal.TenantID, principal.MemberID, body)
} else {
result, err = a.finance.CreatePaymentAccount(r.Context(), principal.TenantID, principal.MemberID, body)
}
if err != nil {
writeFinanceError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func (a *API) paymentAccountDetails(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, r, http.MethodGet)
return
}
principal, ok := a.authenticateFinanceMember(w, r)
if !ok {
return
}
w.Header().Set("Cache-Control", "no-store")
result, err := a.finance.PaymentAccountDetails(r.Context(), principal.TenantID, principal.MemberID)
if err != nil { if err != nil {
writeFinanceError(w, r, err) writeFinanceError(w, r, err)
return return
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"git.118im.com/im_team/im_workbench/internal/mall" "git.118im.com/im_team/olons_server/internal/mall"
"git.118im.com/im_team/im_workbench/internal/store" "git.118im.com/im_team/olons_server/internal/store"
) )
func (a *API) manualProducts(w http.ResponseWriter, r *http.Request) { func (a *API) manualProducts(w http.ResponseWriter, r *http.Request) {
+80 -5
View File
@@ -3,15 +3,19 @@ package httpapi
import ( import (
"errors" "errors"
"net/http" "net/http"
"strconv"
"strings" "strings"
"git.118im.com/im_team/im_workbench/internal/portal" "git.118im.com/im_team/olons_server/internal/portal"
"git.118im.com/im_team/im_workbench/internal/store" "git.118im.com/im_team/olons_server/internal/store"
) )
var publicContentKeys = map[string]struct{}{ var publicContentKeys = map[string]struct{}{
"privacy_policy": {}, "privacy_policy": {},
"user_agreement": {}, "user_agreement": {},
"platform_intro": {},
"platform_introduction": {},
"frequently_asked_questions": {},
} }
func (a *API) home(w http.ResponseWriter, r *http.Request) { func (a *API) home(w http.ResponseWriter, r *http.Request) {
@@ -81,7 +85,7 @@ func (a *API) content(w http.ResponseWriter, r *http.Request) {
writePortalError(w, r, portal.ErrContentKeyInvalid) writePortalError(w, r, portal.ErrContentKeyInvalid)
return return
} }
tenantID := a.auth.DefaultTenantID() tenantID := int64(0)
if _, public := publicContentKeys[key]; !public { if _, public := publicContentKeys[key]; !public {
principal, ok := a.authenticateMember(w, r) principal, ok := a.authenticateMember(w, r)
if !ok { if !ok {
@@ -187,6 +191,77 @@ func (a *API) notices(w http.ResponseWriter, r *http.Request) {
writeSuccess(w, r, result) writeSuccess(w, r, result)
} }
func (a *API) popupNotice(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, r, http.MethodGet)
return
}
if !a.ensurePortal(w, r) {
return
}
principal, ok := a.authenticateMember(w, r)
if !ok {
return
}
result, err := a.portal.PopupNotice(r.Context(), principal.TenantID, principal.MemberID)
if err != nil {
writePortalError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func (a *API) pendingNotice(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "private, no-store")
if r.Method != http.MethodGet {
methodNotAllowed(w, r, http.MethodGet)
return
}
if !a.ensurePortal(w, r) {
return
}
principal, ok := a.authenticateMember(w, r)
if !ok {
return
}
result, err := a.portal.PendingNotice(r.Context(), principal.TenantID, principal.MemberID)
if err != nil {
writePortalError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func (a *API) noticeResource(w http.ResponseWriter, r *http.Request) {
path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/notices/"), "/")
parts := strings.Split(path, "/")
if len(parts) != 2 || parts[1] != "read" {
writePortalError(w, r, store.ErrResourceNotFound)
return
}
noticeID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || noticeID <= 0 {
writePortalError(w, r, store.ErrResourceNotFound)
return
}
if r.Method != http.MethodPost {
methodNotAllowed(w, r, http.MethodPost)
return
}
if !a.ensurePortal(w, r) {
return
}
principal, ok := a.authenticateMember(w, r)
if !ok {
return
}
if err := a.portal.MarkNoticeRead(r.Context(), principal.TenantID, principal.MemberID, noticeID); err != nil {
writePortalError(w, r, err)
return
}
writeSuccess(w, r, struct{}{})
}
func (a *API) ensurePortal(w http.ResponseWriter, r *http.Request) bool { func (a *API) ensurePortal(w http.ResponseWriter, r *http.Request) bool {
if a.portal != nil { if a.portal != nil {
return true return true
+3 -3
View File
@@ -4,9 +4,9 @@ import (
"errors" "errors"
"net/http" "net/http"
accountsvc "git.118im.com/im_team/im_workbench/internal/accounts" accountsvc "git.118im.com/im_team/olons_server/internal/accounts"
"git.118im.com/im_team/im_workbench/internal/auth" "git.118im.com/im_team/olons_server/internal/auth"
"git.118im.com/im_team/im_workbench/internal/store" "git.118im.com/im_team/olons_server/internal/store"
) )
type avatarRequest struct { type avatarRequest struct {
+76
View File
@@ -0,0 +1,76 @@
package httpapi
import (
"errors"
"log"
"net/http"
"strconv"
"strings"
"git.118im.com/im_team/olons_server/internal/support"
)
type customerServiceSessionRequest struct {
DeviceID string `json:"device_id"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
}
func (a *API) customerServiceResource(w http.ResponseWriter, r *http.Request) {
path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/customer-services/"), "/")
parts := strings.Split(path, "/")
if len(parts) != 2 || parts[1] != "session" {
writeError(w, r, http.StatusNotFound, 30004, "RESOURCE_NOT_FOUND", "resource not found", nil)
return
}
serviceID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || serviceID <= 0 {
writeError(w, r, http.StatusNotFound, 30004, "RESOURCE_NOT_FOUND", "resource not found", nil)
return
}
if r.Method != http.MethodPost {
methodNotAllowed(w, r, http.MethodPost)
return
}
principal, ok := a.authenticateMember(w, r)
if !ok {
return
}
if a.support == nil {
writeSupportError(w, r, support.ErrIMUnavailable)
return
}
var body customerServiceSessionRequest
if !decodeBody(w, r, &body) {
return
}
deviceID := strings.TrimSpace(body.DeviceID)
if deviceID == "" {
deviceID = principal.DeviceID
}
result, err := a.support.OpenSession(r.Context(), principal.TenantID, principal.MemberID, serviceID,
support.SessionRequest{DeviceID: deviceID, Platform: body.Platform})
if err != nil {
writeSupportError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func writeSupportError(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("customer service session failed request_id=%s err=%v", r.Header.Get("X-Request-ID"), err)
switch {
case errors.Is(err, support.ErrServiceNotFound):
writeError(w, r, http.StatusNotFound, 41001, "CUSTOMER_SERVICE_NOT_FOUND", "customer service not found", nil)
case errors.Is(err, support.ErrServiceNotBound):
writeError(w, r, http.StatusConflict, 41002, "CUSTOMER_SERVICE_NOT_BOUND", "customer service account is not configured", nil)
case errors.Is(err, support.ErrRelationPending):
writeError(w, r, http.StatusConflict, 41003, "IM_RELATION_PENDING", "customer service relationship is pending", nil)
case errors.Is(err, support.ErrRelationDenied):
writeError(w, r, http.StatusForbidden, 41004, "IM_RELATION_NOT_ALLOWED", "customer service relationship is not allowed", nil)
case errors.Is(err, support.ErrIMUnavailable):
writeError(w, r, http.StatusServiceUnavailable, 41005, "IM_SERVICE_UNAVAILABLE", "IM service unavailable", nil)
default:
writeError(w, r, http.StatusBadGateway, 41006, "IM_SERVICE_UNAVAILABLE", "IM service unavailable", nil)
}
}
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"git.118im.com/im_team/im_workbench/internal/auth" "git.118im.com/im_team/olons_server/internal/auth"
"git.118im.com/im_team/im_workbench/internal/store" "git.118im.com/im_team/olons_server/internal/store"
"git.118im.com/im_team/im_workbench/internal/tasks" "git.118im.com/im_team/olons_server/internal/tasks"
) )
type taskClaimRequest struct { type taskClaimRequest struct {
+425
View File
@@ -0,0 +1,425 @@
package imclient
import (
"bytes"
"context"
"crypto/hmac"
cryptorand "crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"git.118im.com/im_team/olons_server/internal/config"
"github.com/gorilla/websocket"
"golang.org/x/crypto/bcrypt"
)
const maxResponseBody = 2 << 20
const defaultIMTenantCode = "default"
type Error struct {
Status int
Code int
Reason string
Message string
}
func (e *Error) Error() string {
if e == nil {
return "IM service error"
}
if e.Reason != "" {
return e.Reason
}
if e.Message != "" {
return e.Message
}
return fmt.Sprintf("IM service returned HTTP %d", e.Status)
}
type Client struct {
cfg config.IMConfig
http *http.Client
}
type CreatedUser struct {
UserID FlexibleID `json:"user_id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
}
// FlexibleID normalizes the IM internal API's numeric database ID and its
// public string user ID into one representation. User creation returns a
// numeric ID, while session issuance and product APIs return the public ID.
type FlexibleID string
func (id *FlexibleID) UnmarshalJSON(data []byte) error {
var value string
if len(data) > 0 && data[0] == '"' {
if err := json.Unmarshal(data, &value); err != nil {
return err
}
} else {
value = strings.TrimSpace(string(data))
}
if value == "" || value == "null" {
return errors.New("empty IM user ID")
}
*id = FlexibleID(value)
return nil
}
type Session struct {
UserID string `json:"user_id"`
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
type UserProfile struct {
UserID string `json:"user_id"`
DisplayName string `json:"display_name"`
Relationship string `json:"relationship"`
Topic string `json:"topic"`
Capabilities struct {
CanSend bool `json:"can_send"`
} `json:"capabilities"`
}
type FriendRequest struct {
RequestID string `json:"request_id"`
Status string `json:"status"`
Topic string `json:"topic"`
}
type Conversation struct {
Topic string `json:"topic"`
Relationship string `json:"relationship"`
CanSend bool `json:"can_send"`
}
type DirectFriendResult struct {
Relationship string `json:"relationship"`
Topic string `json:"topic"`
}
func New(cfg config.IMConfig) *Client {
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 8 * time.Second
}
return &Client{cfg: cfg, http: &http.Client{Timeout: timeout}}
}
func (c *Client) Config() config.IMConfig { return c.cfg }
func (c *Client) Ready() bool {
return strings.TrimSpace(c.cfg.BaseURL) != "" && strings.TrimSpace(c.cfg.HTTPAPIURL) != "" &&
strings.TrimSpace(c.cfg.WebSocketURL) != "" &&
strings.TrimSpace(c.cfg.TenantCode) != "" && strings.TrimSpace(c.cfg.APIKey) != "" &&
strings.TrimSpace(c.cfg.InternalService) != "" && strings.TrimSpace(c.cfg.InternalSecret) != ""
}
func (c *Client) CreateUser(ctx context.Context, username, nickname, password string) (*CreatedUser, error) {
var out CreatedUser
path := "/internal/admin-query/tenants/" + url.PathEscape(c.cfg.TenantCode) + "/users"
err := c.internalJSON(ctx, http.MethodPost, path, map[string]any{
"username": username, "nickname": nickname, "password": password,
}, &out)
return &out, err
}
func (c *Client) FindUserByUsername(ctx context.Context, username string) (*CreatedUser, error) {
query := url.Values{"auth_name": []string{username}, "page": []string{"1"}, "per_page": []string{"20"}}
path := "/internal/admin-query/tenants/" + url.PathEscape(c.cfg.TenantCode) + "/users?" + query.Encode()
var page struct {
Items []struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
} `json:"items"`
}
if err := c.internalJSON(ctx, http.MethodGet, path, nil, &page); err != nil {
return nil, err
}
for _, item := range page.Items {
if strings.EqualFold(strings.TrimSpace(item.Username), strings.TrimSpace(username)) {
return &CreatedUser{UserID: FlexibleID(item.UserID), Username: item.Username, Nickname: item.Nickname}, nil
}
}
return nil, &Error{Status: http.StatusNotFound, Reason: "RESOURCE_NOT_FOUND", Message: "IM user not found"}
}
func (c *Client) LoginBasic(ctx context.Context, username, password, deviceID, platform string) (*Session, error) {
endpoint, err := url.Parse(strings.TrimSpace(c.cfg.WebSocketURL))
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
return nil, errors.New("IM websocket URL is invalid")
}
query := endpoint.Query()
query.Set("apikey", c.cfg.APIKey)
endpoint.RawQuery = query.Encode()
dialer := websocket.Dialer{HandshakeTimeout: c.cfg.Timeout}
conn, _, err := dialer.DialContext(ctx, endpoint.String(), nil)
if err != nil {
return nil, err
}
defer conn.Close()
deadline := time.Now().Add(c.cfg.Timeout)
_ = conn.SetWriteDeadline(deadline)
_ = conn.SetReadDeadline(deadline)
hiID := "hi-" + requestID()
if err = conn.WriteJSON(map[string]any{"hi": map[string]any{
"id": hiID, "ver": "0.25", "ua": "olons-server/1.0", "dev": deviceID,
"platf": firstNonEmpty(platform, "server"), "lang": "zh-CN", "tenant": c.cfg.TenantCode,
}}); err != nil {
return nil, err
}
if _, err = readCtrl(conn, hiID); err != nil {
return nil, err
}
loginID := "login-" + requestID()
secret := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
loginType := 0
if strings.EqualFold(strings.TrimSpace(c.cfg.TenantCode), defaultIMTenantCode) {
loginType = 1
}
if err = conn.WriteJSON(map[string]any{"login": map[string]any{
"id": loginID, "scheme": "basic", "secret": secret,
"login_type": loginType, "tenant_code": c.cfg.TenantCode,
}}); err != nil {
return nil, err
}
ctrl, err := readCtrl(conn, loginID)
if err != nil {
return nil, err
}
expiresAt, _ := time.Parse(time.RFC3339, ctrl.Params.Expires)
if ctrl.Params.User == "" || ctrl.Params.Token == "" {
return nil, errors.New("IM login response is incomplete")
}
return &Session{UserID: ctrl.Params.User, Token: ctrl.Params.Token, ExpiresAt: expiresAt}, nil
}
func (c *Client) ResetPassword(ctx context.Context, userID, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
path := "/internal/admin-query/tenants/" + url.PathEscape(c.cfg.TenantCode) + "/users/" + url.PathEscape(userID) + "/action"
return c.internalJSON(ctx, http.MethodPost, path, map[string]any{
"event_type": "user.password.reset", "password_hash": base64.StdEncoding.EncodeToString(hash), "reason": "悦选客服系统凭据同步",
}, nil)
}
func (c *Client) UserProfile(ctx context.Context, token, userID string) (*UserProfile, error) {
var out UserProfile
err := c.productJSON(ctx, http.MethodGet, "/users/"+url.PathEscape(userID)+"/profile", token, "", nil, &out)
return &out, err
}
func (c *Client) CreateFriendRequest(ctx context.Context, token, userID, idempotencyKey string) (*FriendRequest, error) {
var out FriendRequest
err := c.productJSON(ctx, http.MethodPost, "/friend-requests", token, idempotencyKey, map[string]any{
"target_user_id": userID, "message": "咨询客服", "source": "profile_card",
}, &out)
return &out, err
}
// AddFriendDirect establishes a trusted server-side friendship without
// creating a user-visible friend request. The IM internal API authenticates
// this operation with the configured internal secret.
func (c *Client) AddFriendDirect(ctx context.Context, userID, peerUserID string) (*DirectFriendResult, error) {
var out DirectFriendResult
path := "/internal/admin-query/tenants/" + url.PathEscape(c.cfg.TenantCode) + "/friends/direct"
err := c.internalJSON(ctx, http.MethodPost, path, map[string]any{
"user_id": userID, "peer_user_id": peerUserID,
}, &out)
return &out, err
}
func (c *Client) EnsureP2P(ctx context.Context, token, userID, idempotencyKey string) (*Conversation, error) {
var out Conversation
err := c.productJSON(ctx, http.MethodPost, "/conversations/p2p", token, idempotencyKey,
map[string]any{"peer_user_id": userID}, &out)
return &out, err
}
func (c *Client) internalJSON(ctx context.Context, method, path string, input, output any) error {
body, err := marshalBody(input)
if err != nil {
return err
}
baseURL := strings.TrimRight(c.cfg.BaseURL, "/")
if baseURL == "" || c.cfg.InternalSecret == "" {
return errors.New("IM internal API is not configured")
}
req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body))
if err != nil {
return err
}
timestamp := strconv.FormatInt(time.Now().UTC().Unix(), 10)
nonce, err := randomNonce()
if err != nil {
return err
}
bodyHash := sha256.Sum256(body)
canonical := strings.Join([]string{method, req.URL.RequestURI(), hex.EncodeToString(bodyHash[:]), timestamp, nonce}, "\n")
mac := hmac.New(sha256.New, []byte(c.cfg.InternalSecret))
_, _ = mac.Write([]byte(canonical))
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Service-Name", c.cfg.InternalService)
req.Header.Set("X-Timestamp", timestamp)
req.Header.Set("X-Nonce", nonce)
req.Header.Set("X-Signature", hex.EncodeToString(mac.Sum(nil)))
return c.do(req, output)
}
func (c *Client) productJSON(ctx context.Context, method, path, token, idempotencyKey string, input, output any) error {
body, err := marshalBody(input)
if err != nil {
return err
}
baseURL := strings.TrimRight(c.cfg.HTTPAPIURL, "/")
if baseURL == "" || c.cfg.APIKey == "" {
return errors.New("IM product API is not configured")
}
req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-MeetingIM-APIKey", c.cfg.APIKey)
req.Header.Set("X-IM-Schema", "1")
req.Header.Set("X-Request-ID", requestID())
req.Header.Set("Authorization", "Bearer "+token)
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
return c.do(req, output)
}
func (c *Client) do(req *http.Request, output any) error {
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
if err != nil {
return err
}
var envelope struct {
Code int `json:"code"`
Reason string `json:"reason"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return &Error{Status: resp.StatusCode, Reason: "INVALID_RESPONSE", Message: "invalid IM response"}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 || envelope.Code != 0 {
return &Error{Status: resp.StatusCode, Code: envelope.Code, Reason: envelope.Reason, Message: envelope.Message}
}
if output == nil || len(envelope.Data) == 0 || string(envelope.Data) == "null" {
return nil
}
return json.Unmarshal(envelope.Data, output)
}
func marshalBody(input any) ([]byte, error) {
if input == nil {
return nil, nil
}
return json.Marshal(input)
}
func randomNonce() (string, error) {
buf := make([]byte, 16)
if _, err := cryptorand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func requestID() string {
buf := make([]byte, 16)
if _, err := cryptorand.Read(buf); err == nil {
return hex.EncodeToString(buf)
}
return strconv.FormatInt(time.Now().UnixNano(), 10)
}
type websocketCtrl struct {
ID string `json:"id"`
Code int `json:"code"`
Text string `json:"text"`
Params struct {
User string `json:"user"`
Token string `json:"token"`
Expires string `json:"expires"`
Reason string `json:"reason"`
} `json:"params"`
}
func readCtrl(conn *websocket.Conn, expectedID string) (*websocketCtrl, error) {
for {
var packet struct {
Ctrl *websocketCtrl `json:"ctrl"`
}
if err := conn.ReadJSON(&packet); err != nil {
return nil, err
}
if packet.Ctrl == nil || packet.Ctrl.ID != expectedID {
continue
}
if packet.Ctrl.Code >= 300 {
reason := firstNonEmpty(packet.Ctrl.Params.Reason, packet.Ctrl.Text, "IM websocket request failed")
return nil, &Error{Status: packet.Ctrl.Code, Reason: reason, Message: packet.Ctrl.Text}
}
return packet.Ctrl, nil
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func IsConflict(err error) bool {
var target *Error
return errors.As(err, &target) && target.Status == http.StatusConflict
}
func IsFriendRequestConflict(err error) bool {
var target *Error
return errors.As(err, &target) && target.Status == http.StatusConflict &&
strings.EqualFold(strings.TrimSpace(target.Reason), "FRIEND_REQUEST_CONFLICT")
}
func IsPermissionDenied(err error) bool {
var target *Error
return errors.As(err, &target) && target.Status == http.StatusForbidden
}
// IsAuthenticationFailed reports whether the IM server rejected user
// credentials. Callers may use this to repair credentials which have drifted
// from the local encrypted copy, while leaving transport failures untouched.
func IsAuthenticationFailed(err error) bool {
var target *Error
return errors.As(err, &target) && target.Status == http.StatusUnauthorized
}
+224
View File
@@ -0,0 +1,224 @@
package imclient
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.118im.com/im_team/olons_server/internal/config"
"github.com/gorilla/websocket"
"golang.org/x/crypto/bcrypt"
)
func TestCreateUserAcceptsNumericIDAndSignsRequest(t *testing.T) {
const secret = "internal-secret"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if r.URL.Path != "/internal/admin-query/tenants/allrich/users" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
if r.Header.Get("X-Service-Name") != "olons-server" {
t.Fatalf("missing service header")
}
canonical := strings.Join([]string{r.Method, r.URL.RequestURI(), hashHex(body),
r.Header.Get("X-Timestamp"), r.Header.Get("X-Nonce")}, "\n")
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(canonical))
if r.Header.Get("X-Signature") != hex.EncodeToString(mac.Sum(nil)) {
t.Fatalf("invalid signature")
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"data":{"user_id":42,"username":"allrich_m_7","nickname":"会员7"}}`))
}))
defer server.Close()
client := New(config.IMConfig{BaseURL: server.URL, HTTPAPIURL: server.URL, TenantCode: "allrich",
APIKey: "public-key", InternalService: "olons-server", InternalSecret: secret, Timeout: time.Second})
created, err := client.CreateUser(context.Background(), "allrich_m_7", "会员7", "password")
if err != nil {
t.Fatal(err)
}
if got := string(created.UserID); got != "42" {
t.Fatalf("user id = %q, want 42", got)
}
}
func TestAddFriendDirectUsesSignedInternalAPI(t *testing.T) {
const secret = "internal-secret"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if r.Method != http.MethodPost || r.URL.Path != "/internal/admin-query/tenants/default/friends/direct" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
if r.Header.Get("X-Service-Name") != "olons-server" {
t.Fatal("missing service header")
}
canonical := strings.Join([]string{r.Method, r.URL.RequestURI(), hashHex(body),
r.Header.Get("X-Timestamp"), r.Header.Get("X-Nonce")}, "\n")
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(canonical))
if r.Header.Get("X-Signature") != hex.EncodeToString(mac.Sum(nil)) {
t.Fatal("invalid signature")
}
var payload map[string]string
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatal(err)
}
if payload["user_id"] != "usr_member" || payload["peer_user_id"] != "usr_agent" {
t.Fatalf("unexpected direct friendship payload: %+v", payload)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"data":{"relationship":"friend","topic":"p2pAB"}}`))
}))
defer server.Close()
client := New(config.IMConfig{BaseURL: server.URL, TenantCode: "default",
InternalService: "olons-server", InternalSecret: secret, Timeout: time.Second})
result, err := client.AddFriendDirect(context.Background(), "usr_member", "usr_agent")
if err != nil {
t.Fatal(err)
}
if result.Relationship != "friend" || result.Topic != "p2pAB" {
t.Fatalf("unexpected direct friendship result: %+v", result)
}
}
func TestProductRequestHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer user-token" || r.Header.Get("X-MeetingIM-APIKey") != "public-key" {
t.Fatalf("missing product API credentials")
}
if r.Header.Get("X-IM-Schema") != "1" {
t.Fatalf("missing schema header")
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"data":{"user_id":"usr_agent","relationship":"friend","topic":"p2p123","capabilities":{"can_send":true}}}`))
}))
defer server.Close()
client := New(config.IMConfig{BaseURL: server.URL, HTTPAPIURL: server.URL, TenantCode: "allrich",
APIKey: "public-key", InternalService: "olons-server", InternalSecret: "secret", Timeout: time.Second})
profile, err := client.UserProfile(context.Background(), "user-token", "usr_agent")
if err != nil {
t.Fatal(err)
}
if profile.Relationship != "friend" || !profile.Capabilities.CanSend {
t.Fatalf("unexpected profile: %+v", profile)
}
}
func TestLoginBasicUsesExistingWebSocketProtocol(t *testing.T) {
upgrader := websocket.Upgrader{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("apikey") != "public-key" {
t.Fatalf("missing API key")
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
var packet map[string]map[string]any
if err = conn.ReadJSON(&packet); err != nil {
t.Fatal(err)
}
if packet["hi"]["tenant"] != "allrich" {
t.Fatalf("unexpected hi: %#v", packet)
}
_ = conn.WriteJSON(map[string]any{"ctrl": map[string]any{"id": packet["hi"]["id"], "code": 201, "text": "created"}})
if err = conn.ReadJSON(&packet); err != nil {
t.Fatal(err)
}
login := packet["login"]
secret, _ := base64.StdEncoding.DecodeString(login["secret"].(string))
if string(secret) != "member01:password" || login["login_type"] != float64(0) || login["tenant_code"] != "allrich" {
t.Fatalf("unexpected login: %#v", login)
}
_ = conn.WriteJSON(map[string]any{"ctrl": map[string]any{"id": login["id"], "code": 200, "text": "ok",
"params": map[string]any{"user": "usr_member", "token": "token", "expires": "2026-10-27T10:00:00Z"}}})
}))
defer server.Close()
client := New(config.IMConfig{BaseURL: server.URL, HTTPAPIURL: server.URL,
WebSocketURL: "ws" + strings.TrimPrefix(server.URL, "http"), TenantCode: "allrich",
APIKey: "public-key", InternalService: "olons-server", InternalSecret: "secret", Timeout: time.Second})
session, err := client.LoginBasic(context.Background(), "member01", "password", "web-1", "web")
if err != nil {
t.Fatal(err)
}
if session.UserID != "usr_member" || session.Token != "token" {
t.Fatalf("unexpected session: %+v", session)
}
}
func TestLoginBasicUsesPersonalTypeForDefaultTenant(t *testing.T) {
upgrader := websocket.Upgrader{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
var packet map[string]map[string]any
if err = conn.ReadJSON(&packet); err != nil {
t.Fatal(err)
}
_ = conn.WriteJSON(map[string]any{"ctrl": map[string]any{"id": packet["hi"]["id"], "code": 201, "text": "created"}})
if err = conn.ReadJSON(&packet); err != nil {
t.Fatal(err)
}
login := packet["login"]
if login["login_type"] != float64(1) || login["tenant_code"] != defaultIMTenantCode {
t.Fatalf("unexpected login: %#v", login)
}
_ = conn.WriteJSON(map[string]any{"ctrl": map[string]any{"id": login["id"], "code": 200, "text": "ok",
"params": map[string]any{"user": "usr_member", "token": "token", "expires": "2026-10-27T10:00:00Z"}}})
}))
defer server.Close()
client := New(config.IMConfig{BaseURL: server.URL, HTTPAPIURL: server.URL,
WebSocketURL: "ws" + strings.TrimPrefix(server.URL, "http"), TenantCode: defaultIMTenantCode,
APIKey: "public-key", InternalService: "olons-server", InternalSecret: "secret", Timeout: time.Second})
if _, err := client.LoginBasic(context.Background(), "member01", "password", "web-1", "web"); err != nil {
t.Fatal(err)
}
}
func TestResetPasswordSendsBase64BcryptHash(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
EventType string `json:"event_type"`
PasswordHash string `json:"password_hash"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
hash, err := base64.StdEncoding.DecodeString(body.PasswordHash)
if body.EventType != "user.password.reset" || err != nil || bcrypt.CompareHashAndPassword(hash, []byte("new-password")) != nil {
t.Fatalf("unexpected reset body: %+v", body)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"data":{"status":"succeeded"}}`))
}))
defer server.Close()
client := New(config.IMConfig{BaseURL: server.URL, HTTPAPIURL: server.URL, WebSocketURL: "ws://example.test",
TenantCode: "allrich", APIKey: "public-key", InternalService: "olons-server", InternalSecret: "secret", Timeout: time.Second})
if err := client.ResetPassword(context.Background(), "usr_member", "new-password"); err != nil {
t.Fatal(err)
}
}
func hashHex(body []byte) string {
digest := sha256.Sum256(body)
return hex.EncodeToString(digest[:])
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"strings" "strings"
"unicode/utf8" "unicode/utf8"
"git.118im.com/im_team/im_workbench/internal/store" "git.118im.com/im_team/olons_server/internal/store"
) )
const ( const (
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"errors" "errors"
"testing" "testing"
"git.118im.com/im_team/im_workbench/internal/store" "git.118im.com/im_team/olons_server/internal/store"
) )
type fakeRepository struct { type fakeRepository struct {
+84 -24
View File
@@ -7,8 +7,8 @@ import (
"regexp" "regexp"
"strings" "strings"
"git.118im.com/im_team/im_workbench/internal/accounts" "git.118im.com/im_team/olons_server/internal/accounts"
"git.118im.com/im_team/im_workbench/internal/store" "git.118im.com/im_team/olons_server/internal/store"
) )
var ( var (
@@ -22,6 +22,8 @@ type Repository interface {
PublishedContent(context.Context, int64, string) (*store.Content, error) PublishedContent(context.Context, int64, string) (*store.Content, error)
ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error) ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error)
ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error) ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error)
NextUnreadMemberNotice(context.Context, int64, int64) (*store.MemberNotice, error)
MarkMemberNoticeRead(context.Context, int64, int64, int64) error
MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error) MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error)
MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error) MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error)
TaskLeaderboard(context.Context, int64, int) ([]store.LeaderboardMember, error) TaskLeaderboard(context.Context, int64, int) ([]store.LeaderboardMember, error)
@@ -51,6 +53,7 @@ type Notice struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Content string `json:"content"` Content string `json:"content"`
BodyHTML string `json:"body_html,omitempty"`
SourceType string `json:"source_type"` SourceType string `json:"source_type"`
StartAt string `json:"start_at"` StartAt string `json:"start_at"`
EndAt string `json:"end_at"` EndAt string `json:"end_at"`
@@ -107,11 +110,11 @@ type MemberLevel struct {
} }
type LeaderboardEntry struct { type LeaderboardEntry struct {
Rank int `json:"rank"` Rank int `json:"rank"`
Username string `json:"username"` Username string `json:"username"`
AvatarURL string `json:"avatar_url"` AvatarURL string `json:"avatar_url"`
CompletedCount int64 `json:"completed_count"` ClaimedPoints int64 `json:"claimed_points"`
Status string `json:"status"` Status string `json:"status"`
} }
type CustomerService struct { type CustomerService struct {
@@ -124,6 +127,7 @@ type CustomerService struct {
ServiceURL string `json:"service_url"` ServiceURL string `json:"service_url"`
BackupContact string `json:"backup_contact"` BackupContact string `json:"backup_contact"`
PrivacyNotice string `json:"privacy_notice"` PrivacyNotice string `json:"privacy_notice"`
IMEnabled bool `json:"im_enabled"`
} }
func New(repository Repository) *Service { func New(repository Repository) *Service {
@@ -203,11 +207,52 @@ func (s *Service) Notices(ctx context.Context, tenantID, memberID int64, page, p
return &NoticePage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil return &NoticePage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil
} }
func (s *Service) PopupNotice(ctx context.Context, tenantID, memberID int64) (*Notice, error) {
row, err := s.repository.PublishedContent(ctx, tenantID, "home_popup_content")
if err != nil {
return nil, err
}
content := strings.TrimSpace(row.BodyText)
if content == "" {
content = strings.TrimSpace(row.Summary)
}
return &Notice{ID: 0, Title: row.Title, Content: content, BodyHTML: row.BodyHTML,
SourceType: "managed_content", CreatedAt: row.PublishedAt}, nil
}
func (s *Service) PendingNotice(ctx context.Context, tenantID, memberID int64) (*Notice, error) {
row, err := s.repository.NextUnreadMemberNotice(ctx, tenantID, memberID)
if err != nil || row == nil {
return nil, err
}
return &Notice{ID: row.ID, Title: row.Title, Content: row.Content,
SourceType: noticeSourceName(row.SourceType), StartAt: row.StartAt,
EndAt: row.EndAt, CreatedAt: row.CreatedAt}, nil
}
func (s *Service) MarkNoticeRead(ctx context.Context, tenantID, memberID, noticeID int64) error {
return s.repository.MarkMemberNoticeRead(ctx, tenantID, memberID, noticeID)
}
func (s *Service) Content(ctx context.Context, tenantID int64, key string) (*Content, error) { func (s *Service) Content(ctx context.Context, tenantID int64, key string) (*Content, error) {
key = strings.ToLower(strings.TrimSpace(key)) key = strings.ToLower(strings.TrimSpace(key))
if !contentKeyPattern.MatchString(key) { if !contentKeyPattern.MatchString(key) {
return nil, ErrContentKeyInvalid return nil, ErrContentKeyInvalid
} }
requestedKey := key
titleOverride := ""
switch key {
case "platform_intro", "user_agreement":
key = "platform_introduction"
if requestedKey == "user_agreement" {
titleOverride = "用户协议"
}
case "privacy_policy":
// 当前后台只维护原型的 12 条正式文本,隐私条款沿用平台协议正文,
// 避免为了前端路由额外制造一条无法在原型中对应的初始化数据。
key = "platform_introduction"
titleOverride = "隐私政策"
}
row, err := s.repository.PublishedContent(ctx, tenantID, key) row, err := s.repository.PublishedContent(ctx, tenantID, key)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -216,7 +261,11 @@ func (s *Service) Content(ctx context.Context, tenantID int64, key string) (*Con
if row.AssetIDs.Valid && strings.TrimSpace(row.AssetIDs.String) != "" { if row.AssetIDs.Valid && strings.TrimSpace(row.AssetIDs.String) != "" {
_ = json.Unmarshal([]byte(row.AssetIDs.String), &assetIDs) _ = json.Unmarshal([]byte(row.AssetIDs.String), &assetIDs)
} }
return &Content{ContentKey: row.ContentKey, ContentType: row.ContentType, Title: row.Title, title := row.Title
if titleOverride != "" {
title = titleOverride
}
return &Content{ContentKey: requestedKey, ContentType: row.ContentType, Title: title,
Summary: row.Summary, BodyHTML: row.BodyHTML, BodyText: row.BodyText, CoverURL: row.CoverURL, Summary: row.Summary, BodyHTML: row.BodyHTML, BodyText: row.BodyText, CoverURL: row.CoverURL,
AssetIDs: assetIDs, Version: row.Version, PublishedAt: row.PublishedAt}, nil AssetIDs: assetIDs, Version: row.Version, PublishedAt: row.PublishedAt}, nil
} }
@@ -248,7 +297,7 @@ func (s *Service) Leaderboard(ctx context.Context, tenantID int64) ([]Leaderboar
items := make([]LeaderboardEntry, 0, len(rows)) items := make([]LeaderboardEntry, 0, len(rows))
for index, row := range rows { for index, row := range rows {
items = append(items, LeaderboardEntry{Rank: index + 1, Username: maskedUsername(row.Username), items = append(items, LeaderboardEntry{Rank: index + 1, Username: maskedUsername(row.Username),
AvatarURL: row.AvatarURL, CompletedCount: row.CompletedCount, Status: "领取成功"}) AvatarURL: row.AvatarURL, ClaimedPoints: row.ClaimedPoints, Status: "领取成功"})
} }
return items, nil return items, nil
} }
@@ -266,6 +315,7 @@ func (s *Service) CustomerServices(ctx context.Context, tenantID int64) ([]Custo
OnlineStatus: customerServiceStatus(row.OnlineStatus), ServiceURL: safeExternalURL(row.ServiceURL), OnlineStatus: customerServiceStatus(row.OnlineStatus), ServiceURL: safeExternalURL(row.ServiceURL),
BackupContact: row.BackupContact, BackupContact: row.BackupContact,
PrivacyNotice: "请勿在客服会话中发送登录密码、支付密码或短信验证码", PrivacyNotice: "请勿在客服会话中发送登录密码、支付密码或短信验证码",
IMEnabled: strings.TrimSpace(row.IMTenantCode) != "" && strings.TrimSpace(row.IMUserID) != "",
}) })
} }
return items, nil return items, nil
@@ -288,30 +338,40 @@ func maskedUsername(value string) string {
func parseFeatures(raw json.RawMessage) Features { func parseFeatures(raw json.RawMessage) Features {
var config map[string]any var config map[string]any
if json.Unmarshal(raw, &config) != nil { if json.Unmarshal(raw, &config) != nil {
return Features{} config = map[string]any{}
} }
return Features{ return Features{
TaskEnabled: findBool(config, "task_enabled", "taskEnabled", "TASK"), TaskEnabled: findBoolDefault(config, true, "task_enabled", "taskEnabled", "TASK"),
RechargeEnabled: findBool(config, "recharge_enabled", "rechargeEnabled", "RECHARGE"), RechargeEnabled: findBoolDefault(config, true, "recharge_enabled", "rechargeEnabled", "RECHARGE"),
WithdrawalEnabled: findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"), WithdrawalEnabled: findBoolDefault(config, true, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"),
ManualMallEnabled: findBool(config, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"), ManualMallEnabled: findBoolDefault(config, true, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"),
} }
} }
func findBool(config map[string]any, keys ...string) bool { func findBoolDefault(config map[string]any, defaultValue bool, keys ...string) bool {
for _, key := range keys { value, found := findBoolValue(config, keys...)
if value, ok := config[key]; ok { if !found {
return boolValue(value) return defaultValue
}
} }
if nested, ok := config["features"].(map[string]any); ok { return value
for _, key := range keys { }
if value, found := nested[key]; found {
return boolValue(value) func findBoolValue(config map[string]any, keys ...string) (bool, bool) {
for _, key := range keys {
for candidate, value := range config {
if strings.EqualFold(candidate, key) {
return boolValue(value), true
} }
} }
} }
return false for _, value := range config {
if nested, ok := value.(map[string]any); ok {
if result, found := findBoolValue(nested, keys...); found {
return result, true
}
}
}
return false, false
} }
func boolValue(value any) bool { func boolValue(value any) bool {
+53 -5
View File
@@ -8,8 +8,8 @@ import (
"strings" "strings"
"testing" "testing"
"git.118im.com/im_team/im_workbench/internal/accounts" "git.118im.com/im_team/olons_server/internal/accounts"
"git.118im.com/im_team/im_workbench/internal/store" "git.118im.com/im_team/olons_server/internal/store"
) )
type fakeRepository struct { type fakeRepository struct {
@@ -42,6 +42,15 @@ func (r fakeRepository) ActiveMemberLevels(context.Context, int64) ([]store.Memb
func (r fakeRepository) ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error) { func (r fakeRepository) ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error) {
return r.notices, nil return r.notices, nil
} }
func (r fakeRepository) NextUnreadMemberNotice(context.Context, int64, int64) (*store.MemberNotice, error) {
if len(r.notices) == 0 {
return nil, nil
}
return &r.notices[0], nil
}
func (r fakeRepository) MarkMemberNoticeRead(context.Context, int64, int64, int64) error {
return nil
}
func (r fakeRepository) MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error) { func (r fakeRepository) MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error) {
if r.noticePage == nil { if r.noticePage == nil {
return &store.MemberNoticePage{Items: []store.MemberNotice{}}, nil return &store.MemberNoticePage{Items: []store.MemberNotice{}}, nil
@@ -68,6 +77,18 @@ func TestParseFeaturesSupportsNestedAndLegacyKeys(t *testing.T) {
} }
} }
func TestParseFeaturesDefaultsMissingSwitchesToEnabled(t *testing.T) {
features := parseFeatures(json.RawMessage(`{}`))
if !features.TaskEnabled || !features.RechargeEnabled || !features.WithdrawalEnabled || !features.ManualMallEnabled {
t.Fatalf("missing switches should default to enabled: %+v", features)
}
features = parseFeatures(json.RawMessage(`{"features":{"task_enabled":false,"withdrawal_enabled":false}}`))
if features.TaskEnabled || features.WithdrawalEnabled || !features.RechargeEnabled || !features.ManualMallEnabled {
t.Fatalf("explicitly disabled switches must remain disabled: %+v", features)
}
}
func TestBannersSanitizeTargets(t *testing.T) { func TestBannersSanitizeTargets(t *testing.T) {
service := New(fakeRepository{banners: []store.Banner{ service := New(fakeRepository{banners: []store.Banner{
{ID: 1, TargetType: 2, TargetValue: "/receive"}, {ID: 1, TargetType: 2, TargetValue: "/receive"},
@@ -144,15 +165,15 @@ func TestHomeUsesSettledSummaryAndSnakeCaseJSON(t *testing.T) {
func TestLeaderboardMasksUsernameAndAddsRank(t *testing.T) { func TestLeaderboardMasksUsernameAndAddsRank(t *testing.T) {
service := New(fakeRepository{leaders: []store.LeaderboardMember{ service := New(fakeRepository{leaders: []store.LeaderboardMember{
{MemberID: 7, Username: "风轻云淡", CompletedCount: 12}, {MemberID: 7, Username: "风轻云淡", ClaimedPoints: 125800},
{MemberID: 8, Username: "李明", CompletedCount: 9}, {MemberID: 8, Username: "李明", ClaimedPoints: 96000},
}}) }})
items, err := service.Leaderboard(context.Background(), 1) items, err := service.Leaderboard(context.Background(), 1)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(items) != 2 || items[0].Rank != 1 || items[0].Username != "风***淡" || if len(items) != 2 || items[0].Rank != 1 || items[0].Username != "风***淡" ||
items[1].Username != "李*" || items[1].Status != "领取成功" { items[0].ClaimedPoints != 125800 || items[1].Username != "李*" || items[1].Status != "领取成功" {
t.Fatalf("unexpected leaderboard: %+v", items) t.Fatalf("unexpected leaderboard: %+v", items)
} }
} }
@@ -193,3 +214,30 @@ func TestNoticesRejectsOversizedPage(t *testing.T) {
t.Fatalf("error = %v, want %v", err, ErrPageSizeInvalid) t.Fatalf("error = %v, want %v", err, ErrPageSizeInvalid)
} }
} }
func TestPopupNoticeUsesManagedContent(t *testing.T) {
service := New(fakeRepository{content: &store.Content{
ContentKey: "home_popup_content", Title: "公告通知", BodyText: "新活动已上线",
PublishedAt: "2026-10-16T08:00:00Z",
}})
notice, err := service.PopupNotice(context.Background(), 1, 7)
if err != nil {
t.Fatal(err)
}
if notice == nil || notice.ID != 0 || notice.Content != "新活动已上线" || notice.SourceType != "managed_content" {
t.Fatalf("unexpected popup notice: %+v", notice)
}
}
func TestContentAliasesPublicAgreementRoutesToManagedText(t *testing.T) {
service := New(fakeRepository{content: &store.Content{
ContentKey: "platform_introduction", Title: "平台简介", BodyText: "服务条款正文",
}})
content, err := service.Content(context.Background(), 1, "user_agreement")
if err != nil {
t.Fatal(err)
}
if content.ContentKey != "user_agreement" || content.Title != "用户协议" || content.BodyText != "服务条款正文" {
t.Fatalf("unexpected aliased content: %+v", content)
}
}
+9 -6
View File
@@ -62,11 +62,14 @@ func (s *Store) RegisterAccount(ctx context.Context, input AccountRegistration)
(tenant_id,member_no,username,mobile,password_hash, (tenant_id,member_no,username,mobile,password_hash,
level_id,parent_id,level,tree,invite_code,member_status,risk_status,auto_dispatch, level_id,parent_id,level,tree,invite_code,member_status,risk_status,auto_dispatch,
credit_score,is_test_account,created_by,updated_by) credit_score,is_test_account,created_by,updated_by)
VALUES(?,?,?,?,?,?,?,?,?,?,1,1,2,100,2,0,0)`, VALUES(?,?,?,?,?,?,?,?,?,?,1,1,1,100,2,0,0)`,
input.TenantID, input.MemberNo, input.Username, input.Mobile, input.PasswordHash, input.TenantID, input.MemberNo, input.Username, input.Mobile, input.PasswordHash,
levelID, parentID, teamLevel, tree, input.InviteCode) levelID, parentID, teamLevel, tree, input.InviteCode)
if err != nil { if err != nil {
if isDuplicate(err) { if isDuplicate(err) {
if strings.Contains(err.Error(), "uk_app_member_invite_code") {
return nil, ErrInviteCodeExists
}
return nil, ErrAccountExists return nil, ErrAccountExists
} }
return nil, err return nil, err
@@ -92,11 +95,11 @@ func (s *Store) RegisterAccount(ctx context.Context, input AccountRegistration)
InviteCode: input.InviteCode, MemberStatus: memberStatusActive, MemberCategory: memberCategoryReal}, nil InviteCode: input.InviteCode, MemberStatus: memberStatusActive, MemberCategory: memberCategoryReal}, nil
} }
func (s *Store) AccountByMobile(ctx context.Context, tenantID int64, mobile string) (*Account, error) { func (s *Store) AccountByMobile(ctx context.Context, mobile string) (*Account, error) {
if s == nil || s.db == nil { if s == nil || s.db == nil {
return nil, errors.New("store is unavailable") return nil, errors.New("store is unavailable")
} }
return accountByMobile(ctx, s.db, tenantID, mobile) return accountByMobile(ctx, s.db, mobile)
} }
func (s *Store) RecordLogin(ctx context.Context, tenantID, memberID int64) error { func (s *Store) RecordLogin(ctx context.Context, tenantID, memberID int64) error {
@@ -109,11 +112,11 @@ type queryer interface {
GetContext(context.Context, any, string, ...any) error GetContext(context.Context, any, string, ...any) error
} }
func accountByMobile(ctx context.Context, db queryer, tenantID int64, mobile string) (*Account, error) { func accountByMobile(ctx context.Context, db queryer, mobile string) (*Account, error) {
var row Account var row Account
err := db.GetContext(ctx, &row, `SELECT tenant_id, id AS member_id, member_no, username, mobile, err := db.GetContext(ctx, &row, `SELECT tenant_id, id AS member_id, member_no, username, mobile,
password_hash, invite_code, member_status, risk_status AS member_category password_hash, invite_code, member_status, risk_status AS member_category
FROM hg_app_member WHERE tenant_id=? AND mobile=? AND deleted_at IS NULL LIMIT 1`, tenantID, mobile) FROM hg_app_member WHERE mobile=? AND deleted_at IS NULL LIMIT 1`, mobile)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInvalidCredentials return nil, ErrInvalidCredentials
} }
@@ -129,7 +132,7 @@ func accountByMobile(ctx context.Context, db queryer, tenantID int64, mobile str
func referralParent(ctx context.Context, tx *sqlx.Tx, tenantID int64, code string) (int64, int, string, error) { func referralParent(ctx context.Context, tx *sqlx.Tx, tenantID int64, code string) (int64, int, string, error) {
code = strings.TrimSpace(code) code = strings.TrimSpace(code)
if code == "" { if code == "" {
return 0, 1, "", nil return 0, 0, "", ErrReferralInvalid
} }
var parent struct { var parent struct {
ID int64 `db:"id"` ID int64 `db:"id"`
+37
View File
@@ -0,0 +1,37 @@
package store
import (
"context"
"strings"
"testing"
)
type accountQueryRecorder struct {
query string
args []any
}
func (r *accountQueryRecorder) GetContext(_ context.Context, dest any, query string, args ...any) error {
r.query = query
r.args = args
account := dest.(*Account)
*account = Account{MemberID: 9, Mobile: "13311111111", MemberStatus: memberStatusActive}
return nil
}
func TestAccountByMobileDoesNotFilterTenant(t *testing.T) {
recorder := &accountQueryRecorder{}
account, err := accountByMobile(context.Background(), recorder, "13311111111")
if err != nil {
t.Fatal(err)
}
if account.MemberID != 9 {
t.Fatalf("MemberID = %d, want 9", account.MemberID)
}
if strings.Contains(strings.ToLower(recorder.query), "tenant_id=?") {
t.Fatalf("query must not filter tenant: %s", recorder.query)
}
if len(recorder.args) != 1 || recorder.args[0] != "13311111111" {
t.Fatalf("query args = %#v", recorder.args)
}
}
+35
View File
@@ -0,0 +1,35 @@
package store
import "context"
type AttachmentCreation struct {
MemberID int64
Drive string
Name string
MimeType string
Path string
URL string
Size int64
Ext string
MD5 string
}
type Attachment struct {
ID int64 `json:"id"`
URL string `json:"url"`
}
func (s *Store) CreateAttachment(ctx context.Context, input AttachmentCreation) (*Attachment, error) {
result, err := s.db.ExecContext(ctx, `INSERT INTO hg_sys_attachment
(app_id,member_id,cate_id,drive,name,kind,mime_type,naive_type,path,file_url,size,ext,md5,status,created_at,updated_at)
VALUES('olons_server',?,0,?,?,'image',?,'image',?,?,?, ?,?,1,UTC_TIMESTAMP(),UTC_TIMESTAMP())`,
input.MemberID, input.Drive, input.Name, input.MimeType, input.Path, input.URL, input.Size, input.Ext, input.MD5)
if err != nil {
return nil, err
}
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
return &Attachment{ID: id, URL: input.URL}, nil
}
+1
View File
@@ -4,6 +4,7 @@ import "errors"
var ( var (
ErrAccountExists = errors.New("account already exists") ErrAccountExists = errors.New("account already exists")
ErrInviteCodeExists = errors.New("invite code already exists")
ErrAccountNotFound = errors.New("account not found") ErrAccountNotFound = errors.New("account not found")
ErrAccountRestricted = errors.New("account restricted") ErrAccountRestricted = errors.New("account restricted")
ErrAttachmentInvalid = errors.New("attachment invalid") ErrAttachmentInvalid = errors.New("attachment invalid")
@@ -0,0 +1,174 @@
package store
import (
"context"
"os"
"testing"
"github.com/jmoiron/sqlx"
)
func financeTimeTestDB(t *testing.T) *sqlx.DB {
t.Helper()
dsn := os.Getenv("OLONS_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("set OLONS_TEST_MYSQL_DSN to run the finance time integration tests")
}
db, err := sqlx.Connect("mysql", dsn)
if err != nil {
t.Fatal(err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
t.Cleanup(func() { _ = db.Close() })
return db
}
func TestFinanceRecordTimesUseCorrectSourceZone(t *testing.T) {
db := financeTimeTestDB(t)
// Temporary tables shadow only this connection's tables; no business rows are changed.
for _, query := range []string{
`CREATE TEMPORARY TABLE hg_payment_channel (id BIGINT,tenant_id BIGINT,name VARCHAR(64))`,
`CREATE TEMPORARY TABLE hg_recharge_order (
id BIGINT,tenant_id BIGINT,member_id BIGINT,recharge_no VARCHAR(64),channel_id BIGINT,
request_amount BIGINT DEFAULT 50000,paid_amount BIGINT DEFAULT 50000,currency VARCHAR(8) DEFAULT 'CNY',
voucher_attachment_ids JSON,recharge_status INT DEFAULT 3,remark VARCHAR(255) DEFAULT '',
review_reason_code VARCHAR(64) DEFAULT '',review_reason VARCHAR(255) DEFAULT '',external_no VARCHAR(64) DEFAULT '',
paid_at DATETIME,reviewed_at DATETIME,credited_at DATETIME,created_at DATETIME,updated_at DATETIME)`,
`CREATE TEMPORARY TABLE hg_withdrawal_order (
id BIGINT,tenant_id BIGINT,member_id BIGINT,withdrawal_no VARCHAR(64),payment_account_id BIGINT DEFAULT 1,
channel_id BIGINT,request_amount BIGINT DEFAULT 50000,fee_amount BIGINT DEFAULT 0,actual_amount BIGINT DEFAULT 50000,
currency VARCHAR(8) DEFAULT 'CNY',account_mask VARCHAR(64) DEFAULT '',bank_name VARCHAR(64) DEFAULT '',
holder_mask VARCHAR(64) DEFAULT '',risk_result JSON,withdrawal_status INT DEFAULT 5,
review_reason_code VARCHAR(64) DEFAULT '',review_reason VARCHAR(255) DEFAULT '',external_no VARCHAR(64) DEFAULT '',
reviewed_at DATETIME,processed_at DATETIME,completed_at DATETIME,created_at DATETIME,updated_at DATETIME)`,
} {
if _, err := db.Exec(query); err != nil {
t.Fatal(err)
}
}
s := &Store{db: db}
ctx := context.Background()
for _, test := range []struct{ zone, createdAt string }{
{"+00:00", "2026-10-22 15:59:00"},
{"+08:00", "2026-10-22 23:59:00"},
} {
t.Run(test.zone, func(t *testing.T) {
for _, query := range []string{`DELETE FROM hg_recharge_order`, `DELETE FROM hg_withdrawal_order`} {
if _, err := db.Exec(query); err != nil {
t.Fatal(err)
}
}
if _, err := db.Exec(`SET SESSION time_zone=?`, test.zone); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO hg_recharge_order
(id,tenant_id,member_id,recharge_no,channel_id,created_at,updated_at,paid_at,reviewed_at,credited_at)
VALUES(1,0,9,'RT1',1,?,'2026-10-23 00:01:00','2026-10-23 00:01:00','2026-10-23 00:01:00','2026-10-23 00:01:00'),
(2,0,9,'RT2',1,?,?,NULL,NULL,NULL)`, test.createdAt, test.createdAt, test.createdAt); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO hg_withdrawal_order
(id,tenant_id,member_id,withdrawal_no,channel_id,created_at,updated_at,reviewed_at,processed_at,completed_at)
VALUES(1,0,9,'WT1',1,?,'2026-10-23 00:01:00','2026-10-23 00:01:00','2026-10-23 00:01:00','2026-10-23 00:01:00'),
(2,0,9,'WT2',1,?,?,NULL,NULL,NULL)`, test.createdAt, test.createdAt, test.createdAt); err != nil {
t.Fatal(err)
}
recharges, err := s.RechargeOrders(ctx, 0, 9, 0, 0, 50)
if err != nil || recharges == nil || len(recharges.Items) != 2 {
t.Fatalf("recharges = %+v, err = %v", recharges, err)
}
withdrawals, err := s.WithdrawalOrders(ctx, 0, 9, 0, 0, 50)
if err != nil || withdrawals == nil || len(withdrawals.Items) != 2 {
t.Fatalf("withdrawals = %+v, err = %v", withdrawals, err)
}
for _, item := range recharges.Items {
if item.CreatedAt != "2026-10-22T15:59:00Z" || (item.ID == 1 && item.UpdatedAt != "2026-10-22T16:01:00Z") || (item.ID == 2 && item.UpdatedAt != item.CreatedAt) {
t.Errorf("recharge list time = %+v", item)
}
}
for _, item := range withdrawals.Items {
if item.CreatedAt != "2026-10-22T15:59:00Z" || (item.ID == 1 && item.UpdatedAt != "2026-10-22T16:01:00Z") || (item.ID == 2 && item.UpdatedAt != item.CreatedAt) {
t.Errorf("withdrawal list time = %+v", item)
}
}
recharge, err := s.RechargeOrder(ctx, 0, 9, 1)
if err != nil {
t.Fatal(err)
}
if recharge.PaidAt != "2026-10-22T16:01:00Z" || recharge.ReviewedAt != recharge.PaidAt || recharge.CreditedAt != recharge.PaidAt {
t.Errorf("recharge review timestamps = %+v", recharge)
}
withdrawal, err := s.WithdrawalOrder(ctx, 0, 9, 1)
if err != nil {
t.Fatal(err)
}
if withdrawal.ReviewedAt != "2026-10-22T16:01:00Z" || withdrawal.ProcessedAt != withdrawal.ReviewedAt || withdrawal.CompletedAt != withdrawal.ReviewedAt {
t.Errorf("withdrawal review timestamps = %+v", withdrawal)
}
recharge, err = s.RechargeOrder(ctx, 0, 9, 2)
if err != nil || recharge == nil || recharge.PaidAt != "" || recharge.ReviewedAt != "" || recharge.CreditedAt != "" {
t.Errorf("empty recharge review times = %+v, err = %v", recharge, err)
}
withdrawal, err = s.WithdrawalOrder(ctx, 0, 9, 2)
if err != nil || withdrawal == nil || withdrawal.ReviewedAt != "" || withdrawal.ProcessedAt != "" || withdrawal.CompletedAt != "" {
t.Errorf("empty withdrawal review times = %+v, err = %v", withdrawal, err)
}
})
}
}
func TestWalletAndOrderLedgerTimesKeepUTCAndBeijingWriters(t *testing.T) {
db := financeTimeTestDB(t)
if _, err := db.Exec(`CREATE TEMPORARY TABLE hg_wallet_ledger (
id BIGINT,tenant_id BIGINT DEFAULT 0,member_id BIGINT DEFAULT 9,ledger_no VARCHAR(64),
biz_type VARCHAR(64) DEFAULT 'commission',biz_no VARCHAR(64) DEFAULT 'ORDER1',entry_type VARCHAR(64) DEFAULT 'available',
direction INT DEFAULT 1,amount BIGINT DEFAULT 100,before_amount BIGINT DEFAULT 1000,after_amount BIGINT DEFAULT 1100,
currency VARCHAR(8) DEFAULT 'CNY',remark VARCHAR(255) DEFAULT '',idempotency_key VARCHAR(128),occurred_at DATETIME)`); err != nil {
t.Fatal(err)
}
fixtures := []struct{ key, remark, occurredAt, want string }{
{"task:1:settle:commission", "任务完成自动返佣", "2026-10-22 16:00:07", "2026-10-22T16:00:07Z"},
{"recharge:2", "储入审核到账", "2026-10-22 23:59:07", "2026-10-22T15:59:07Z"},
{"withdrawal:W1:available", "申请取出冻结", "2026-10-22 15:58:07", "2026-10-22T15:58:07Z"},
{"withdraw:1:consume", "取出审核", "2026-10-22 23:57:07", "2026-10-22T15:57:07Z"},
{"task:2:admin-match:commission", "后台匹配订单完成返佣", "2026-10-22 23:56:07", "2026-10-22T15:56:07Z"},
{"task:3:match:available", "自动派单扣除可用余额", "2026-10-22 15:55:07", "2026-10-22T15:55:07Z"},
{"task:4:match:available", "手动订单强制付款扣除可用余额", "2026-10-22 23:54:07", "2026-10-22T15:54:07Z"},
{"task:5:match:frozen", "会员确认手动订单冻结本金", "2026-10-22 15:53:07", "2026-10-22T15:53:07Z"},
{"task:6:expire:available", "订单超时退回订单本金", "2026-10-22 15:52:07", "2026-10-22T15:52:07Z"},
}
for i, fixture := range fixtures {
if _, err := db.Exec(`INSERT INTO hg_wallet_ledger (id,ledger_no,idempotency_key,remark,occurred_at) VALUES(?,?,?,?,?)`,
i+1, fixture.key, fixture.key, fixture.remark, fixture.occurredAt); err != nil {
t.Fatal(err)
}
}
for _, zone := range []string{"+00:00", "+08:00"} {
t.Run(zone, func(t *testing.T) {
if _, err := db.Exec(`SET SESSION time_zone=?`, zone); err != nil {
t.Fatal(err)
}
s := &Store{db: db}
page, err := s.WalletLedgers(context.Background(), 0, 9, "", 0, 0, 50)
if err != nil || page == nil || len(page.Items) != len(fixtures) {
t.Fatalf("ledger page = %+v, err = %v", page, err)
}
for i, fixture := range fixtures {
if page.Items[i].LedgerNo != fixture.key || page.Items[i].OccurredAt != fixture.want {
t.Errorf("ledger %d = %+v, want %s at %s", i, page.Items[i], fixture.key, fixture.want)
}
}
rows, err := s.OrderLedgers(context.Background(), 0, 9, []string{"ORDER1"})
if err != nil || len(rows) != len(fixtures) {
t.Fatalf("order ledgers = %+v, err = %v", rows, err)
}
for i, row := range rows {
fixture := fixtures[len(fixtures)-i-1]
if row["ledger_no"] != fixture.key || row["occurred_at"] != fixture.want {
t.Errorf("order ledger = %+v, want %s at %s", row, fixture.key, fixture.want)
}
}
})
}
}
+1 -2
View File
@@ -73,8 +73,7 @@ func manualProductSelectSQL() string {
COALESCE(p.detail_html,'') AS detail_html,(p.is_recommended=1) AS is_recommended, COALESCE(p.detail_html,'') AS detail_html,(p.is_recommended=1) AS is_recommended,
COALESCE(DATE_FORMAT(p.start_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS start_at, COALESCE(DATE_FORMAT(p.start_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS start_at,
COALESCE(DATE_FORMAT(p.end_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS end_at, COALESCE(DATE_FORMAT(p.end_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS end_at,
(SELECT COUNT(*) FROM hg_trade_order o WHERE o.tenant_id=p.tenant_id AND o.product_id=p.id p.claimed_count,
AND o.order_type=3 AND o.order_status NOT IN (7,8)) AS claimed_count,
DATE_FORMAT(p.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at, DATE_FORMAT(p.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
DATE_FORMAT(p.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at DATE_FORMAT(p.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
FROM hg_product p JOIN hg_product_category c FROM hg_product p JOIN hg_product_category c
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context" "context"
"time" "time"
"git.118im.com/im_team/im_workbench/internal/config" "git.118im.com/im_team/olons_server/internal/config"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
+76 -12
View File
@@ -5,10 +5,21 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"time"
) )
const publishStatusPublished = 3 const publishStatusPublished = 3
var noticeBusinessLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
func noticeBusinessNow() string {
return formatNoticeBusinessTime(time.Now())
}
func formatNoticeBusinessTime(value time.Time) string {
return value.In(noticeBusinessLocation).Format("2006-01-02 15:04:05.999999999")
}
type Banner struct { type Banner struct {
ID int64 `db:"id"` ID int64 `db:"id"`
ImageURL string `db:"image_url"` ImageURL string `db:"image_url"`
@@ -68,10 +79,10 @@ type TaskSummary struct {
} }
type LeaderboardMember struct { type LeaderboardMember struct {
MemberID int64 `db:"member_id"` MemberID int64 `db:"member_id"`
Username string `db:"username"` Username string `db:"username"`
AvatarURL string `db:"avatar_url"` AvatarURL string `db:"avatar_url"`
CompletedCount int64 `db:"completed_count"` ClaimedPoints int64 `db:"claimed_points"`
} }
type CustomerService struct { type CustomerService struct {
@@ -83,6 +94,9 @@ type CustomerService struct {
OnlineStatus int `db:"online_status"` OnlineStatus int `db:"online_status"`
ServiceURL string `db:"service_url"` ServiceURL string `db:"service_url"`
BackupContact string `db:"backup_contact"` BackupContact string `db:"backup_contact"`
IMTenantCode string `db:"im_tenant_code"`
IMUserID string `db:"im_user_id"`
IMUsername string `db:"im_username"`
} }
func (s *Store) CurrentSiteConfig(ctx context.Context, tenantID int64) (json.RawMessage, error) { func (s *Store) CurrentSiteConfig(ctx context.Context, tenantID int64) (json.RawMessage, error) {
@@ -151,23 +165,72 @@ func (s *Store) ActiveMemberNotices(ctx context.Context, tenantID, memberID int6
return nil, errors.New("store is unavailable") return nil, errors.New("store is unavailable")
} }
rows := make([]MemberNotice, 0) rows := make([]MemberNotice, 0)
now := noticeBusinessNow()
err := s.db.SelectContext(ctx, &rows, `SELECT id,title,content,source_type, err := s.db.SelectContext(ctx, &rows, `SELECT id,title,content,source_type,
DATE_FORMAT(start_at,'%Y-%m-%dT%H:%i:%sZ') AS start_at, DATE_FORMAT(start_at,'%Y-%m-%dT%H:%i:%sZ') AS start_at,
DATE_FORMAT(end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_at, DATE_FORMAT(end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_at,
DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at
FROM hg_app_member_notice WHERE tenant_id=? AND member_id IN (0,?) AND notice_status=1 FROM hg_app_member_notice WHERE tenant_id=? AND member_id IN (0,?) AND notice_status=1
AND start_at<=UTC_TIMESTAMP(3) AND end_at>UTC_TIMESTAMP(3) AND start_at<=? AND end_at>?
ORDER BY start_at DESC,id DESC LIMIT ?`, tenantID, memberID, limit) ORDER BY start_at DESC,id DESC LIMIT ?`, tenantID, memberID, now, now, limit)
return rows, err return rows, err
} }
func (s *Store) NextUnreadMemberNotice(ctx context.Context, tenantID, memberID int64) (*MemberNotice, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
var row MemberNotice
now := noticeBusinessNow()
err := s.db.GetContext(ctx, &row, `SELECT n.id,n.title,n.content,n.source_type,
DATE_FORMAT(n.start_at,'%Y-%m-%dT%H:%i:%sZ') AS start_at,
DATE_FORMAT(n.end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_at,
DATE_FORMAT(n.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at
FROM hg_app_member_notice n
WHERE n.tenant_id=? AND n.member_id=? AND n.notice_status=1
AND n.start_at<=? AND n.end_at>?
AND NOT EXISTS (SELECT 1 FROM hg_app_member_notice_read r
WHERE r.tenant_id=n.tenant_id AND r.notice_id=n.id AND r.member_id=?)
ORDER BY n.start_at ASC,n.id ASC LIMIT 1`, tenantID, memberID, now, now, memberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return &row, err
}
func (s *Store) MarkMemberNoticeRead(ctx context.Context, tenantID, memberID, noticeID int64) error {
if s == nil || s.db == nil {
return errors.New("store is unavailable")
}
now := noticeBusinessNow()
result, err := s.db.ExecContext(ctx, `INSERT INTO hg_app_member_notice_read
(tenant_id,notice_id,member_id,read_at,created_at)
SELECT n.tenant_id,n.id,?,?,?
FROM hg_app_member_notice n
WHERE n.tenant_id=? AND n.id=? AND n.member_id IN (0,?) AND n.notice_status=1
AND n.start_at<=? AND n.end_at>?
ON DUPLICATE KEY UPDATE read_at=VALUES(read_at)`, memberID, now, now, tenantID, noticeID, memberID, now, now)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return ErrResourceNotFound
}
return nil
}
func (s *Store) MemberNotices(ctx context.Context, tenantID, memberID int64, offset, limit int) (*MemberNoticePage, error) { func (s *Store) MemberNotices(ctx context.Context, tenantID, memberID int64, offset, limit int) (*MemberNoticePage, error) {
if s == nil || s.db == nil { if s == nil || s.db == nil {
return nil, errors.New("store is unavailable") return nil, errors.New("store is unavailable")
} }
const where = ` WHERE tenant_id=? AND member_id IN (0,?) AND notice_status=1 const where = ` WHERE tenant_id=? AND member_id IN (0,?) AND notice_status=1
AND start_at<=UTC_TIMESTAMP(3) AND end_at>UTC_TIMESTAMP(3)` AND start_at<=? AND end_at>?`
args := []any{tenantID, memberID} now := noticeBusinessNow()
args := []any{tenantID, memberID, now, now}
var total int64 var total int64
if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_app_member_notice`+where, args...); err != nil { if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_app_member_notice`+where, args...); err != nil {
return nil, err return nil, err
@@ -204,15 +267,15 @@ func (s *Store) TaskLeaderboard(ctx context.Context, tenantID int64, limit int)
} }
rows := make([]LeaderboardMember, 0) rows := make([]LeaderboardMember, 0)
err := s.db.SelectContext(ctx, &rows, `SELECT m.id AS member_id,m.username, err := s.db.SelectContext(ctx, &rows, `SELECT m.id AS member_id,m.username,
COALESCE(a.file_url,'') AS avatar_url,COUNT(o.id) AS completed_count COALESCE(a.file_url,'') AS avatar_url,COALESCE(SUM(o.total_amount),0) AS claimed_points
FROM hg_app_member m FROM hg_app_member m
LEFT JOIN hg_app_member_profile p ON p.tenant_id=m.tenant_id AND p.member_id=m.id LEFT JOIN hg_app_member_profile p ON p.tenant_id=m.tenant_id AND p.member_id=m.id
LEFT JOIN hg_sys_attachment a ON a.id=p.avatar_attachment_id AND a.status=1 LEFT JOIN hg_sys_attachment a ON a.id=p.avatar_attachment_id AND a.status=1
JOIN hg_trade_order o ON o.tenant_id=m.tenant_id AND o.member_id=m.id JOIN hg_trade_order o ON o.tenant_id=m.tenant_id AND o.member_id=m.id
AND o.order_type=2 AND o.order_status=4 AND o.order_type=2 AND o.order_status=4
WHERE m.tenant_id=? AND m.member_status=1 AND m.deleted_at IS NULL WHERE m.tenant_id=? AND m.member_status=1 AND m.is_test_account=2 AND m.deleted_at IS NULL
GROUP BY m.id,m.username,a.file_url GROUP BY m.id,m.username,a.file_url
ORDER BY completed_count DESC,m.id ASC LIMIT ?`, tenantID, limit) ORDER BY claimed_points DESC,m.id ASC LIMIT ?`, tenantID, limit)
return rows, err return rows, err
} }
@@ -222,7 +285,8 @@ func (s *Store) ActiveCustomerServices(ctx context.Context, tenantID int64) ([]C
} }
rows := make([]CustomerService, 0) rows := make([]CustomerService, 0)
err := s.db.SelectContext(ctx, &rows, `SELECT id,service_code,name,avatar_url,work_time, err := s.db.SelectContext(ctx, &rows, `SELECT id,service_code,name,avatar_url,work_time,
online_status,service_url,backup_contact FROM hg_customer_service online_status,service_url,backup_contact,im_tenant_code,COALESCE(im_user_id,'') AS im_user_id,
COALESCE(im_username,'') AS im_username FROM hg_customer_service
WHERE tenant_id=? AND status=1 AND deleted_at IS NULL ORDER BY sort ASC,id ASC`, tenantID) WHERE tenant_id=? AND status=1 AND deleted_at IS NULL ORDER BY sort ASC,id ASC`, tenantID)
return rows, err return rows, err
} }
@@ -0,0 +1,85 @@
package store
import (
"context"
"errors"
"os"
"testing"
"time"
"github.com/jmoiron/sqlx"
)
func TestPendingNoticeAudienceAndReadAcknowledgement(t *testing.T) {
dsn := os.Getenv("OLONS_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("set OLONS_TEST_MYSQL_DSN to run the notice integration test")
}
db, err := sqlx.Connect("mysql", dsn)
if err != nil {
t.Fatal(err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
t.Cleanup(func() { _ = db.Close() })
// Connection-local temporary tables shadow the real tables without changing application data.
for _, statement := range []string{
`CREATE TEMPORARY TABLE hg_app_member_notice (
id BIGINT PRIMARY KEY, tenant_id BIGINT, member_id BIGINT,
title VARCHAR(120), content TEXT, source_type INT, notice_status INT,
start_at DATETIME(3), end_at DATETIME(3), created_at DATETIME(3))`,
`CREATE TEMPORARY TABLE hg_app_member_notice_read (
id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_id BIGINT, notice_id BIGINT,
member_id BIGINT, read_at DATETIME(3), created_at DATETIME(3),
UNIQUE KEY (tenant_id, notice_id, member_id))`,
} {
if _, err := db.Exec(statement); err != nil {
t.Fatal(err)
}
}
now := time.Now()
for _, notice := range []struct {
id, tenantID, memberID int64
status int
start, end time.Time
}{
{1, 0, 9, 1, now.Add(-2 * time.Hour), now.Add(time.Hour)},
{2, 0, 9, 1, now.Add(-time.Hour), now.Add(time.Hour)},
{3, 0, 10, 1, now.Add(-time.Hour), now.Add(time.Hour)},
{4, 1, 9, 1, now.Add(-time.Hour), now.Add(time.Hour)},
{5, 0, 0, 1, now.Add(-time.Hour), now.Add(time.Hour)},
{6, 0, 9, 2, now.Add(-time.Hour), now.Add(time.Hour)},
{7, 0, 9, 1, now.Add(-2 * time.Hour), now.Add(-time.Hour)},
{8, 0, 9, 1, now.Add(time.Hour), now.Add(2 * time.Hour)},
} {
_, err := db.Exec(`INSERT INTO hg_app_member_notice
(id,tenant_id,member_id,title,content,source_type,notice_status,start_at,end_at,created_at)
VALUES (?,?,?,'系统通知','通知内容',1,?,?,?,?)`, notice.id, notice.tenantID, notice.memberID,
notice.status, formatNoticeBusinessTime(notice.start), formatNoticeBusinessTime(notice.end), formatNoticeBusinessTime(now))
if err != nil {
t.Fatal(err)
}
}
ctx := context.Background()
s := &Store{db: db}
for _, expectedID := range []int64{1, 2} {
notice, err := s.NextUnreadMemberNotice(ctx, 0, 9)
if err != nil || notice == nil || notice.ID != expectedID {
t.Fatalf("pending notice = %+v, error = %v, want id %d", notice, err, expectedID)
}
if err := s.MarkMemberNoticeRead(ctx, 0, 9, expectedID); err != nil {
t.Fatal(err)
}
}
if notice, err := s.NextUnreadMemberNotice(ctx, 0, 9); err != nil || notice != nil {
t.Fatalf("read, expired, revoked, foreign and broadcast notices must not pop up: %+v, %v", notice, err)
}
for _, forbiddenID := range []int64{3, 4} {
if err := s.MarkMemberNoticeRead(ctx, 0, 9, forbiddenID); !errors.Is(err, ErrResourceNotFound) {
t.Fatalf("reading foreign notice %d: error = %v", forbiddenID, err)
}
}
if notice, err := s.NextUnreadMemberNotice(ctx, 0, 10); err != nil || notice == nil || notice.ID != 3 {
t.Fatalf("other recipient must retain their unread notice: %+v, %v", notice, err)
}
}
+13
View File
@@ -0,0 +1,13 @@
package store
import (
"testing"
"time"
)
func TestFormatNoticeBusinessTimeUsesShanghaiWallClock(t *testing.T) {
value := time.Date(2026, 10, 22, 6, 12, 38, 0, time.UTC)
if got, want := formatNoticeBusinessTime(value), "2026-10-22 14:12:38"; got != want {
t.Fatalf("formatNoticeBusinessTime() = %q, want %q", got, want)
}
}
+4 -2
View File
@@ -13,6 +13,7 @@ type MemberOverview struct {
Username string `db:"username"` Username string `db:"username"`
Mobile string `db:"mobile"` Mobile string `db:"mobile"`
InviteCode string `db:"invite_code"` InviteCode string `db:"invite_code"`
HasTradePassword int `db:"has_trade_password"`
MemberStatus int `db:"member_status"` MemberStatus int `db:"member_status"`
MemberCategory int `db:"member_category"` MemberCategory int `db:"member_category"`
CreditScore int `db:"credit_score"` CreditScore int `db:"credit_score"`
@@ -49,7 +50,8 @@ func (s *Store) MemberOverview(ctx context.Context, tenantID, memberID int64) (*
} }
var row MemberOverview var row MemberOverview
err := s.db.GetContext(ctx, &row, `SELECT m.tenant_id,m.id AS member_id,m.member_no,m.username,m.mobile, err := s.db.GetContext(ctx, &row, `SELECT m.tenant_id,m.id AS member_id,m.member_no,m.username,m.mobile,
m.invite_code,m.member_status,m.risk_status AS member_category,m.credit_score, m.invite_code,CASE WHEN m.trade_password_hash <> '' THEN 1 ELSE 0 END AS has_trade_password,
m.member_status,m.risk_status AS member_category,m.credit_score,
COALESCE(p.avatar_attachment_id,0) AS avatar_attachment_id,COALESCE(a.file_url,'') AS avatar_url, COALESCE(p.avatar_attachment_id,0) AS avatar_attachment_id,COALESCE(a.file_url,'') AS avatar_url,
COALESCE(l.id,0) AS level_id,COALESCE(l.level_code,'') AS level_code, COALESCE(l.id,0) AS level_id,COALESCE(l.level_code,'') AS level_code,
COALESCE(l.name,'') AS level_name,COALESCE(l.icon_url,'') AS level_icon_url, COALESCE(l.name,'') AS level_name,COALESCE(l.icon_url,'') AS level_icon_url,
@@ -92,7 +94,7 @@ func (s *Store) UpdateAvatar(ctx context.Context, tenantID, memberID, attachment
var avatar Avatar var avatar Avatar
err = tx.GetContext(ctx, &avatar, `SELECT id AS attachment_id,COALESCE(file_url,'') AS url FROM hg_sys_attachment err = tx.GetContext(ctx, &avatar, `SELECT id AS attachment_id,COALESCE(file_url,'') AS url FROM hg_sys_attachment
WHERE id=? AND app_id='workbench' AND member_id=? AND status=1 WHERE id=? AND app_id='olons_server' AND member_id=? AND status=1
AND mime_type LIKE 'image/%' LIMIT 1 FOR UPDATE`, attachmentID, memberID) AND mime_type LIKE 'image/%' LIMIT 1 FOR UPDATE`, attachmentID, memberID)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAttachmentInvalid return nil, ErrAttachmentInvalid
+12 -9
View File
@@ -56,6 +56,8 @@ type RechargePage struct {
Total int64 Total int64
} }
const rechargeStatusReview = 2
func (s *Store) RechargeChannels(ctx context.Context, tenantID int64) ([]RechargeChannel, error) { func (s *Store) RechargeChannels(ctx context.Context, tenantID int64) ([]RechargeChannel, error) {
items := make([]RechargeChannel, 0) items := make([]RechargeChannel, 0)
err := s.db.SelectContext(ctx, &items, `SELECT id,channel_code,name,channel_type,min_amount,max_amount, err := s.db.SelectContext(ctx, &items, `SELECT id,channel_code,name,channel_type,min_amount,max_amount,
@@ -95,7 +97,7 @@ func (s *Store) CreateRecharge(ctx context.Context, input RechargeCreation) (*Re
for _, attachmentID := range input.VoucherAttachmentIDs { for _, attachmentID := range input.VoucherAttachmentIDs {
var count int var count int
if err = tx.GetContext(ctx, &count, `SELECT COUNT(*) FROM hg_sys_attachment WHERE tenant_id=? AND id=? if err = tx.GetContext(ctx, &count, `SELECT COUNT(*) FROM hg_sys_attachment WHERE tenant_id=? AND id=?
AND app_id='workbench' AND member_id=? AND status=1`, input.TenantID, attachmentID, input.MemberID); err != nil { AND app_id='olons_server' AND member_id=? AND status=1`, input.TenantID, attachmentID, input.MemberID); err != nil {
return nil, err return nil, err
} }
if count != 1 { if count != 1 {
@@ -109,8 +111,8 @@ func (s *Store) CreateRecharge(ctx context.Context, input RechargeCreation) (*Re
result, err := tx.ExecContext(ctx, `INSERT INTO hg_recharge_order result, err := tx.ExecContext(ctx, `INSERT INTO hg_recharge_order
(tenant_id,recharge_no,member_id,channel_id,request_amount,paid_amount,currency, (tenant_id,recharge_no,member_id,channel_id,request_amount,paid_amount,currency,
voucher_attachment_ids,recharge_type,recharge_status,remark,created_by,updated_by) voucher_attachment_ids,recharge_type,recharge_status,remark,created_by,updated_by)
VALUES(?,?,?,?,?,0,?,?,1,1,?,?,?)`, input.TenantID, input.RechargeNo, input.MemberID, VALUES(?,?,?,?,?,0,?,?,1,?,?,?,?)`, input.TenantID, input.RechargeNo, input.MemberID,
input.ChannelID, input.RequestAmount, input.Currency, vouchers, input.Remark, input.MemberID, input.MemberID) input.ChannelID, input.RequestAmount, input.Currency, vouchers, rechargeStatusReview, input.Remark, input.MemberID, input.MemberID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -123,7 +125,7 @@ func (s *Store) CreateRecharge(ctx context.Context, input RechargeCreation) (*Re
} }
return &RechargeOrder{ID: id, RechargeNo: input.RechargeNo, ChannelID: channel.ID, return &RechargeOrder{ID: id, RechargeNo: input.RechargeNo, ChannelID: channel.ID,
ChannelName: channel.Name, RequestAmount: input.RequestAmount, Currency: input.Currency, ChannelName: channel.Name, RequestAmount: input.RequestAmount, Currency: input.Currency,
VoucherAttachmentIDs: vouchers, Status: 1, Remark: input.Remark}, nil VoucherAttachmentIDs: vouchers, Status: rechargeStatusReview, Remark: input.Remark}, nil
} }
func (s *Store) RechargeOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*RechargePage, error) { func (s *Store) RechargeOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*RechargePage, error) {
@@ -158,10 +160,11 @@ func rechargeSelectSQL() string {
return `SELECT o.id,o.recharge_no,o.channel_id,COALESCE(c.name,'') AS channel_name,o.request_amount, return `SELECT o.id,o.recharge_no,o.channel_id,COALESCE(c.name,'') AS channel_name,o.request_amount,
o.paid_amount,o.currency,COALESCE(o.voucher_attachment_ids,JSON_ARRAY()) AS voucher_attachment_ids, o.paid_amount,o.currency,COALESCE(o.voucher_attachment_ids,JSON_ARRAY()) AS voucher_attachment_ids,
o.recharge_status,o.remark,o.review_reason_code,o.review_reason,o.external_no, o.recharge_status,o.remark,o.review_reason_code,o.review_reason,o.external_no,
COALESCE(DATE_FORMAT(o.paid_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS paid_at, COALESCE(` + beijingWallTimeUTC("o.paid_at") + `,'') AS paid_at,
COALESCE(DATE_FORMAT(o.reviewed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS reviewed_at, COALESCE(` + beijingWallTimeUTC("o.reviewed_at") + `,'') AS reviewed_at,
COALESCE(DATE_FORMAT(o.credited_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS credited_at, COALESCE(` + beijingWallTimeUTC("o.credited_at") + `,'') AS credited_at,
DATE_FORMAT(o.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at, ` + databaseWallTimeUTC("o.created_at") + ` AS created_at,
DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at CASE WHEN o.reviewed_at IS NULL THEN ` + databaseWallTimeUTC("o.updated_at") + `
ELSE ` + beijingWallTimeUTC("o.updated_at") + ` END AS updated_at
FROM hg_recharge_order o LEFT JOIN hg_payment_channel c ON c.tenant_id=o.tenant_id AND c.id=o.channel_id` FROM hg_recharge_order o LEFT JOIN hg_payment_channel c ON c.tenant_id=o.tenant_id AND c.id=o.channel_id`
} }
+89
View File
@@ -0,0 +1,89 @@
package store
import (
"context"
"database/sql"
"errors"
)
type MemberIMIdentity struct {
ID int64 `db:"id"`
TenantID int64 `db:"tenant_id"`
MemberID int64 `db:"member_id"`
IMTenantCode string `db:"im_tenant_code"`
IMUserID string `db:"im_user_id"`
IMUsername string `db:"im_username"`
IMPasswordCipher string `db:"im_password_cipher"`
}
type MemberIMProfile struct {
MemberID int64 `db:"member_id"`
Username string `db:"username"`
MemberNo string `db:"member_no"`
}
func (s *Store) CustomerServiceByID(ctx context.Context, tenantID, serviceID int64) (*CustomerService, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
var row CustomerService
err := s.db.GetContext(ctx, &row, `SELECT id,service_code,name,avatar_url,work_time,online_status,
service_url,backup_contact,im_tenant_code,COALESCE(im_user_id,'') AS im_user_id,
COALESCE(im_username,'') AS im_username FROM hg_customer_service
WHERE tenant_id=? AND id=? AND status=1 AND deleted_at IS NULL LIMIT 1`, tenantID, serviceID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrResourceNotFound
}
return &row, err
}
func (s *Store) MemberIMIdentityForTenant(ctx context.Context, tenantID, memberID int64, imTenantCode string) (*MemberIMIdentity, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
var row MemberIMIdentity
err := s.db.GetContext(ctx, &row, `SELECT id,tenant_id,member_id,im_tenant_code,im_user_id,im_username,im_password_cipher
FROM hg_app_member_im_identity WHERE tenant_id=? AND member_id=? AND im_tenant_code=? LIMIT 1`,
tenantID, memberID, imTenantCode)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrResourceNotFound
}
return &row, err
}
func (s *Store) MemberIMProfile(ctx context.Context, tenantID, memberID int64) (*MemberIMProfile, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
var row MemberIMProfile
err := s.db.GetContext(ctx, &row, `SELECT id AS member_id,username,member_no FROM hg_app_member
WHERE tenant_id=? AND id=? AND member_status=1 AND deleted_at IS NULL LIMIT 1`, tenantID, memberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAccountNotFound
}
return &row, err
}
func (s *Store) SaveMemberIMIdentity(ctx context.Context, identity *MemberIMIdentity) (*MemberIMIdentity, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
if identity == nil {
return nil, errors.New("identity is required")
}
_, err := s.db.ExecContext(ctx, `INSERT INTO hg_app_member_im_identity
(tenant_id,member_id,im_tenant_code,im_user_id,im_username,im_password_cipher,sync_status,last_error,created_at,updated_at)
VALUES(?,?,?,?,?,?,1,'',UTC_TIMESTAMP(3),UTC_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE im_user_id=VALUES(im_user_id),im_username=VALUES(im_username),
im_password_cipher=VALUES(im_password_cipher),sync_status=1,last_error='',updated_at=UTC_TIMESTAMP(3)`,
identity.TenantID, identity.MemberID, identity.IMTenantCode, identity.IMUserID, identity.IMUsername, identity.IMPasswordCipher)
if err == nil {
return s.MemberIMIdentityForTenant(ctx, identity.TenantID, identity.MemberID, identity.IMTenantCode)
}
// A concurrent request may have completed the same deterministic mapping.
existing, findErr := s.MemberIMIdentityForTenant(ctx, identity.TenantID, identity.MemberID, identity.IMTenantCode)
if findErr == nil {
return existing, nil
}
return nil, err
}
+427 -65
View File
@@ -2,9 +2,14 @@ package store
import ( import (
"context" "context"
cryptorand "crypto/rand"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"math/big"
"strconv"
"strings"
"time" "time"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
@@ -35,6 +40,7 @@ type TaskHall struct {
type TaskEligibility struct { type TaskEligibility struct {
MemberLevelID int64 `db:"member_level_id"` MemberLevelID int64 `db:"member_level_id"`
MemberStatus int `db:"member_status"` MemberStatus int `db:"member_status"`
AutoDispatch int `db:"auto_dispatch"`
HallEligible bool `db:"hall_eligible"` HallEligible bool `db:"hall_eligible"`
AvailableAmount int64 `db:"available_amount"` AvailableAmount int64 `db:"available_amount"`
WalletStatus int `db:"wallet_status"` WalletStatus int `db:"wallet_status"`
@@ -48,6 +54,7 @@ type ClaimTaskInput struct {
MemberID int64 MemberID int64
HallID int64 HallID int64
MinimumAmount int64 MinimumAmount int64
AutoDelay int
DeadlineAt time.Time DeadlineAt time.Time
OrderNo string OrderNo string
} }
@@ -104,12 +111,15 @@ type TaskStats struct {
} }
type TaskSubmissionInput struct { type TaskSubmissionInput struct {
TenantID int64 TenantID int64
MemberID int64 MemberID int64
OrderID int64 OrderID int64
SubmissionNo string SubmissionNo string
EvidenceText string EvidenceText string
AttachmentIDs []int64 AttachmentIDs []int64
LedgerNo string
IdempotencyKey string
SettledAt time.Time
} }
type TaskSubmissionResult struct { type TaskSubmissionResult struct {
@@ -130,7 +140,7 @@ func (s *Store) TaskHalls(ctx context.Context, tenantID, memberID int64) ([]Task
func (s *Store) TaskEligibility(ctx context.Context, tenantID, memberID, hallID int64) (*TaskEligibility, error) { func (s *Store) TaskEligibility(ctx context.Context, tenantID, memberID, hallID int64) (*TaskEligibility, error) {
var row TaskEligibility var row TaskEligibility
err := s.db.GetContext(ctx, &row, `SELECT m.level_id AS member_level_id,m.member_status, err := s.db.GetContext(ctx, &row, `SELECT m.level_id AS member_level_id,m.member_status,m.auto_dispatch,
EXISTS(SELECT 1 FROM hg_task_hall h JOIN hg_task_hall_level hl EXISTS(SELECT 1 FROM hg_task_hall h JOIN hg_task_hall_level hl
ON hl.tenant_id=h.tenant_id AND hl.hall_id=h.id AND hl.level_id=m.level_id ON hl.tenant_id=h.tenant_id AND hl.hall_id=h.id AND hl.level_id=m.level_id
WHERE h.tenant_id=m.tenant_id AND h.id=? AND h.publish_status=3 AND h.deleted_at IS NULL) AS hall_eligible, WHERE h.tenant_id=m.tenant_id AND h.id=? AND h.publish_status=3 AND h.deleted_at IS NULL) AS hall_eligible,
@@ -159,9 +169,10 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
defer tx.Rollback() defer tx.Rollback()
var member struct { var member struct {
LevelID int64 `db:"level_id"` LevelID int64 `db:"level_id"`
AutoDispatch int `db:"auto_dispatch"`
} }
err = tx.GetContext(ctx, &member, `SELECT level_id FROM hg_app_member err = tx.GetContext(ctx, &member, `SELECT level_id,auto_dispatch FROM hg_app_member
WHERE tenant_id=? AND id=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID) WHERE tenant_id=? AND id=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAccountNotFound return nil, ErrAccountNotFound
@@ -207,8 +218,12 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
if pending > 0 { if pending > 0 {
return nil, ErrPendingOrderExists return nil, ErrPendingOrderExists
} }
var dailyLimit int var levelRule struct {
err = tx.GetContext(ctx, &dailyLimit, `SELECT COALESCE(v.daily_order_limit,0) FROM hg_app_member_level l DailyLimit int `db:"daily_order_limit"`
RuleJSON json.RawMessage `db:"rule_json"`
}
err = tx.GetContext(ctx, &levelRule, `SELECT COALESCE(v.daily_order_limit,0) AS daily_order_limit,
COALESCE(v.rule_json,JSON_OBJECT()) AS rule_json FROM hg_app_member_level l
LEFT JOIN hg_app_member_level_version v ON v.tenant_id=l.tenant_id AND v.id=l.current_version_id AND v.publish_status=3 LEFT JOIN hg_app_member_level_version v ON v.tenant_id=l.tenant_id AND v.id=l.current_version_id AND v.publish_status=3
WHERE l.tenant_id=? AND l.id=? AND l.status=1 AND l.deleted_at IS NULL LIMIT 1`, input.TenantID, member.LevelID) WHERE l.tenant_id=? AND l.id=? AND l.status=1 AND l.deleted_at IS NULL LIMIT 1`, input.TenantID, member.LevelID)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -217,44 +232,53 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
if err != nil { if err != nil {
return nil, err return nil, err
} }
if dailyLimit > 0 { if levelRule.DailyLimit > 0 {
var todayCount int var todayCount int
if err := tx.GetContext(ctx, &todayCount, `SELECT COUNT(*) FROM hg_trade_order WHERE tenant_id=? AND member_id=? if err := tx.GetContext(ctx, &todayCount, `SELECT COUNT(*) FROM hg_trade_order WHERE tenant_id=? AND member_id=?
AND order_type=2 AND created_at>=UTC_DATE() AND created_at<UTC_DATE()+INTERVAL 1 DAY`, input.TenantID, input.MemberID); err != nil { AND order_type=2 AND created_at>=UTC_DATE() AND created_at<UTC_DATE()+INTERVAL 1 DAY`, input.TenantID, input.MemberID); err != nil {
return nil, err return nil, err
} }
if todayCount >= dailyLimit { if todayCount >= levelRule.DailyLimit {
return nil, ErrDailyLimitReached return nil, ErrDailyLimitReached
} }
} }
var task struct { dispatchMode, message := "manual", "等待后台匹配订单"
ID int64 `db:"id"` matchAt := time.Now().UTC()
Title string `db:"title"` deadlineAt := input.DeadlineAt.UTC()
CoverURL string `db:"cover_url"` delaySeconds := 0
CommissionAmount int64 `db:"commission_amount"` if member.AutoDispatch == 1 {
CommissionRate int `db:"commission_rate"` dispatchMode, message = "auto", "正在自动匹配订单"
PerMemberLimit int `db:"per_member_limit"` delayMin, delayMax := input.AutoDelay, input.AutoDelay
} var levelRules map[string]any
err = tx.GetContext(ctx, &task, `SELECT t.id,t.title,t.cover_url,t.commission_amount,t.commission_rate,t.per_member_limit if json.Unmarshal(levelRule.RuleJSON, &levelRules) == nil {
FROM hg_task t JOIN hg_task_level tl ON tl.tenant_id=t.tenant_id AND tl.task_id=t.id AND tl.level_id=? if value, ok := jsonInt(levelRules["auto_order_delay_min_seconds"]); ok {
WHERE t.tenant_id=? AND t.hall_id=? AND t.publish_status=3 AND t.deleted_at IS NULL delayMin = value
AND (t.start_at IS NULL OR t.start_at<=UTC_TIMESTAMP(3)) AND (t.end_at IS NULL OR t.end_at>UTC_TIMESTAMP(3)) }
AND (t.daily_limit=0 OR (SELECT COUNT(*) FROM hg_trade_order od WHERE od.tenant_id=t.tenant_id AND od.task_id=t.id if value, ok := jsonInt(levelRules["auto_order_delay_max_seconds"]); ok {
AND od.order_type=2 AND od.created_at>=UTC_DATE() AND od.created_at<UTC_DATE()+INTERVAL 1 DAY)<t.daily_limit) delayMax = value
AND (t.per_member_limit=0 OR (SELECT COUNT(*) FROM hg_trade_order om WHERE om.tenant_id=t.tenant_id }
AND om.task_id=t.id AND om.member_id=? AND om.order_type=2 AND om.created_at>=UTC_DATE() AND om.created_at<UTC_DATE()+INTERVAL 1 DAY)<t.per_member_limit) }
ORDER BY t.sort,t.id LIMIT 1 FOR UPDATE`, member.LevelID, input.TenantID, input.HallID, input.MemberID) if delayMin < 0 {
if errors.Is(err, sql.ErrNoRows) { delayMin = 0
return nil, ErrNoTaskAvailable }
} if delayMax < delayMin {
if err != nil { delayMax = delayMin
return nil, err }
delaySeconds = randomDelaySeconds(delayMin, delayMax)
matchAt = matchAt.Add(time.Duration(delaySeconds) * time.Second)
completionWindow := input.DeadlineAt.UTC().Sub(time.Now().UTC())
if completionWindow > 0 {
deadlineAt = matchAt.Add(completionWindow)
}
} }
rules, err := json.Marshal(map[string]any{ rules, err := json.Marshal(map[string]any{
"commission_amount": task.CommissionAmount, "source": "member_auto_claim",
"commission_rate": task.CommissionRate, "dispatch_mode": dispatchMode,
"deadline_at": input.DeadlineAt.UTC().Format(time.RFC3339), "hall_id": input.HallID,
"member_level_id": member.LevelID, "deadline_at": deadlineAt.Format(time.RFC3339),
"scheduled_match_at": matchAt.Format(time.RFC3339),
"dispatch_delay_seconds": delaySeconds,
"member_level_id": member.LevelID,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -262,10 +286,9 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
result, err := tx.ExecContext(ctx, `INSERT INTO hg_trade_order result, err := tx.ExecContext(ctx, `INSERT INTO hg_trade_order
(tenant_id,order_no,member_id,task_id,product_id,product_name,order_type,quantity, (tenant_id,order_no,member_id,task_id,product_id,product_name,order_type,quantity,
unit_price_amount,total_amount,commission_amount,currency,member_level_id,rule_snapshot, unit_price_amount,total_amount,commission_amount,currency,member_level_id,rule_snapshot,
message,order_status,freeze_status,matched_at,created_by,updated_by) message,order_status,freeze_status,created_by,updated_by)
VALUES(?,?,?,?,0,?,2,1,0,0,?,'CNY',?,?,?,2,1,UTC_TIMESTAMP(3),0,0)`, VALUES(?,?,?,0,0,'',2,0,0,0,0,'CNY',?,?,?,1,1,0,0)`,
input.TenantID, input.OrderNo, input.MemberID, task.ID, task.Title, task.CommissionAmount, input.TenantID, input.OrderNo, input.MemberID, member.LevelID, rules, message)
member.LevelID, rules, "任务匹配成功,请在截止时间前完成")
if err != nil { if err != nil {
if isDuplicate(err) { if isDuplicate(err) {
return nil, ErrPendingOrderExists return nil, ErrPendingOrderExists
@@ -279,11 +302,209 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return nil, err return nil, err
} }
return &TaskOrder{ID: orderID, OrderNo: input.OrderNo, TaskID: task.ID, ProductName: task.Title, return &TaskOrder{ID: orderID, OrderNo: input.OrderNo, Currency: "CNY",
CoverURL: task.CoverURL, Quantity: 1, CommissionAmount: task.CommissionAmount, Currency: "CNY", MemberLevelID: member.LevelID, RuleSnapshot: rules, Message: message,
MemberLevelID: member.LevelID, RuleSnapshot: rules, Message: "任务匹配成功,请在截止时间前完成", OrderStatus: orderStatusMatching, FreezeStatus: 1, DeadlineAt: deadlineAt.Format(time.RFC3339),
OrderStatus: orderStatusPending, FreezeStatus: 1, DeadlineAt: input.DeadlineAt.UTC().Format(time.RFC3339), CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil
MatchedAt: time.Now().UTC().Format(time.RFC3339), CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil }
func (s *Store) AutoMatchTaskOrder(ctx context.Context, tenantID, memberID, orderID int64, delaySeconds int, minAmount, maxAmount int64) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var order struct {
OrderNo string `db:"order_no"`
Status int `db:"order_status"`
MemberLevelID int64 `db:"member_level_id"`
RuleSnapshot json.RawMessage `db:"rule_snapshot"`
AutoDispatch int `db:"auto_dispatch"`
CreatedAt time.Time `db:"created_at"`
}
err = tx.GetContext(ctx, &order, `SELECT o.order_no,o.order_status,o.member_level_id,o.rule_snapshot,o.created_at,m.auto_dispatch
FROM hg_trade_order o JOIN hg_app_member m ON m.tenant_id=o.tenant_id AND m.id=o.member_id
WHERE o.tenant_id=? AND o.member_id=? AND o.id=? AND o.order_type=2 LIMIT 1 FOR UPDATE`, tenantID, memberID, orderID)
if errors.Is(err, sql.ErrNoRows) {
return ErrOrderNotFound
}
if err != nil {
return err
}
if order.Status != orderStatusMatching {
return tx.Commit()
}
matchAt := order.CreatedAt.UTC().Add(time.Duration(delaySeconds) * time.Second)
snapshot := map[string]any{}
if len(order.RuleSnapshot) > 0 && json.Unmarshal(order.RuleSnapshot, &snapshot) == nil {
if raw, ok := snapshot["scheduled_match_at"].(string); ok {
if parsed, parseErr := time.Parse(time.RFC3339, raw); parseErr == nil {
matchAt = parsed.UTC()
}
}
}
dispatchMode, _ := snapshot["dispatch_mode"].(string)
isAutoDispatch := dispatchMode == "auto" || dispatchMode == "" && order.AutoDispatch == 1
if !isAutoDispatch || time.Now().UTC().Before(matchAt) {
return tx.Commit()
}
var account struct {
ID int64 `db:"id"`
AvailableAmount int64 `db:"available_amount"`
FrozenAmount int64 `db:"frozen_amount"`
Version int `db:"version"`
}
err = tx.GetContext(ctx, &account, `SELECT id,available_amount,frozen_amount,version FROM hg_wallet_account
WHERE tenant_id=? AND member_id=? AND currency='CNY' AND account_status=1 LIMIT 1 FOR UPDATE`, tenantID, memberID)
if errors.Is(err, sql.ErrNoRows) {
return ErrAccountRestricted
}
if err != nil {
return err
}
lowerAmount := max(int64(1), minAmount)
upperAmount := account.AvailableAmount
if maxAmount > 0 && maxAmount < upperAmount {
upperAmount = maxAmount
}
if lowerAmount > upperAmount {
return ErrNoTaskAvailable
}
var product struct {
ID int64 `db:"id"`
ProductNo string `db:"product_no"`
Name string `db:"name"`
PriceAmount int64 `db:"price_amount"`
Currency string `db:"currency"`
}
err = tx.GetContext(ctx, &product, `SELECT id,product_no,name,price_amount,currency FROM hg_product
WHERE tenant_id=? AND publish_status=3 AND deleted_at IS NULL AND currency='CNY' AND price_amount>=? AND price_amount<=?
AND (start_at IS NULL OR start_at<=UTC_TIMESTAMP(3)) AND (end_at IS NULL OR end_at>UTC_TIMESTAMP(3))
ORDER BY RAND() LIMIT 1 FOR UPDATE`, tenantID, lowerAmount, upperAmount)
if errors.Is(err, sql.ErrNoRows) {
return ErrNoTaskAvailable
}
if err != nil {
return err
}
var levelRule struct {
VersionID int64 `db:"version_id"`
CommissionRate int `db:"commission_rate"`
}
err = tx.GetContext(ctx, &levelRule, `SELECT v.id AS version_id,v.commission_rate
FROM hg_app_member_level l JOIN hg_app_member_level_version v
ON v.tenant_id=l.tenant_id AND v.id=l.current_version_id AND v.publish_status=3
WHERE l.tenant_id=? AND l.id=? AND l.status=1 AND l.deleted_at IS NULL LIMIT 1`, tenantID, order.MemberLevelID)
if errors.Is(err, sql.ErrNoRows) {
return ErrLevelNotEligible
}
if err != nil {
return err
}
snapshot["match_source"] = "system_auto_match"
snapshot["product_id"] = product.ID
snapshot["product_no"] = product.ProductNo
snapshot["unit_price_amount"] = product.PriceAmount
snapshot["quantity"] = 1
snapshot["level_version_id"] = levelRule.VersionID
snapshot["commission_rate"] = levelRule.CommissionRate
rules, err := json.Marshal(snapshot)
if err != nil {
return err
}
commissionAmount := product.PriceAmount * int64(levelRule.CommissionRate) / 10000
holdAvailable := account.AvailableAmount - product.PriceAmount
holdFrozen := account.FrozenAmount + product.PriceAmount
settledAvailable := account.AvailableAmount + commissionAmount
now := time.Now().UTC()
walletResult, err := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,total_income=total_income+?,version=version+1,
updated_at=UTC_TIMESTAMP(3) WHERE tenant_id=? AND id=? AND version=? AND available_amount=? AND frozen_amount=?`,
settledAvailable, account.FrozenAmount, commissionAmount, tenantID, account.ID, account.Version, account.AvailableAmount, account.FrozenAmount)
if err != nil {
return err
}
if affected, _ := walletResult.RowsAffected(); affected != 1 {
return ErrConcurrentUpdate
}
if _, err = insertTaskLedger(ctx, tx, tenantID, memberID, account.ID, orderID, fmt.Sprintf("WL%dMA", orderID),
"task_principal_hold", order.OrderNo, "available", 2, product.PriceAmount, account.AvailableAmount, holdAvailable,
"match:available", "自动派单扣除可用余额"); err != nil {
return err
}
if _, err = insertTaskLedger(ctx, tx, tenantID, memberID, account.ID, orderID, fmt.Sprintf("WL%dMF", orderID),
"task_principal_hold", order.OrderNo, "frozen", 1, product.PriceAmount, account.FrozenAmount, holdFrozen,
"match:frozen", "自动派单冻结订单本金"); err != nil {
return err
}
if _, err = insertTaskLedger(ctx, tx, tenantID, memberID, account.ID, orderID, fmt.Sprintf("WL%dSF", orderID),
"task_principal_release", order.OrderNo, "frozen", 2, product.PriceAmount, holdFrozen, account.FrozenAmount,
"settle:frozen", "自动订单完成释放冻结本金"); err != nil {
return err
}
if _, err = insertTaskLedger(ctx, tx, tenantID, memberID, account.ID, orderID, fmt.Sprintf("WL%dSA", orderID),
"task_principal_return", order.OrderNo, "available", 1, product.PriceAmount, holdAvailable, account.AvailableAmount,
"settle:available", "自动订单完成返回本金"); err != nil {
return err
}
submissionNo := fmt.Sprintf("TSAUTO%d", orderID)
commissionLedgerID, err := insertTaskLedger(ctx, tx, tenantID, memberID, account.ID, orderID, fmt.Sprintf("WL%dSC", orderID),
"commission", submissionNo, "available", 1, commissionAmount, account.AvailableAmount, settledAvailable,
"settle:commission", "自动订单完成返佣")
if err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO hg_task_submission
(tenant_id,submission_no,trade_order_id,task_id,member_id,member_level_id,evidence_text,
evidence_attachment_ids,submission_status,commission_amount,success_rate,reviewed_by,reviewed_at,
review_reason_code,review_reason,allow_resubmit,ledger_id,settled_at,created_by,updated_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,'',JSON_ARRAY(),4,?,10000,0,?,'AUTO_MATCHED','自动派单完成',2,?,?,0,0,?,?)`,
tenantID, submissionNo, orderID, 0, memberID, order.MemberLevelID, commissionAmount, now,
commissionLedgerID, now, now, now); err != nil {
return err
}
result, err := tx.ExecContext(ctx, `UPDATE hg_trade_order SET product_id=?,product_name=?,quantity=1,
unit_price_amount=?,total_amount=?,commission_amount=?,currency=?,rule_snapshot=?,message=?,
order_status=4,freeze_status=1,matched_at=?,completed_at=?,updated_at=?
WHERE tenant_id=? AND member_id=? AND id=? AND order_type=2 AND order_status=1`, product.ID, product.Name,
product.PriceAmount, product.PriceAmount, commissionAmount, product.Currency, rules,
"系统已自动匹配并完成订单,佣金已入账", now, now, now, tenantID, memberID, orderID)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected != 1 {
return ErrConcurrentUpdate
}
return tx.Commit()
}
func jsonInt(value any) (int, bool) {
switch typed := value.(type) {
case float64:
return int(typed), true
case json.Number:
parsed, err := typed.Int64()
return int(parsed), err == nil
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(typed))
return parsed, err == nil
default:
return 0, false
}
}
func randomDelaySeconds(minimum, maximum int) int {
if maximum <= minimum {
return minimum
}
value, err := cryptorand.Int(cryptorand.Reader, big.NewInt(int64(maximum-minimum+1)))
if err != nil {
return minimum
}
return minimum + int(value.Int64())
} }
func (s *Store) TaskOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*TaskOrderPage, error) { func (s *Store) TaskOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*TaskOrderPage, error) {
@@ -320,7 +541,7 @@ func orderSelectSQL() string {
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.rule_snapshot,'$.deadline_at')),'') AS deadline_at, COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.rule_snapshot,'$.deadline_at')),'') AS deadline_at,
COALESCE(DATE_FORMAT(o.matched_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS matched_at, COALESCE(DATE_FORMAT(o.matched_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS matched_at,
COALESCE(DATE_FORMAT(o.completed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS completed_at, COALESCE(DATE_FORMAT(o.completed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS completed_at,
DATE_FORMAT(o.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at, ` + databaseWallTimeUTC("o.created_at") + ` AS created_at,
DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at, DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at,
COALESCE(t.title,'') AS task_title,COALESCE(t.description,'') AS task_description, COALESCE(t.title,'') AS task_title,COALESCE(t.description,'') AS task_description,
COALESCE(t.detail_html,'') AS task_detail_html,COALESCE(s.submission_no,'') AS submission_no, COALESCE(t.detail_html,'') AS task_detail_html,COALESCE(s.submission_no,'') AS submission_no,
@@ -364,13 +585,16 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
} }
defer tx.Rollback() defer tx.Rollback()
var order struct { var order struct {
TaskID int64 `db:"task_id"` OrderNo string `db:"order_no"`
MemberLevelID int64 `db:"member_level_id"` TaskID int64 `db:"task_id"`
CommissionAmount int64 `db:"commission_amount"` MemberLevelID int64 `db:"member_level_id"`
OrderStatus int `db:"order_status"` TotalAmount int64 `db:"total_amount"`
DeadlineAt string `db:"deadline_at"` CommissionAmount int64 `db:"commission_amount"`
OrderStatus int `db:"order_status"`
DeadlineAt string `db:"deadline_at"`
RuleSnapshot json.RawMessage `db:"rule_snapshot"`
} }
err = tx.GetContext(ctx, &order, `SELECT task_id,member_level_id,commission_amount,order_status, err = tx.GetContext(ctx, &order, `SELECT order_no,task_id,member_level_id,total_amount,commission_amount,order_status,rule_snapshot,
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(rule_snapshot,'$.deadline_at')),'') AS deadline_at COALESCE(JSON_UNQUOTE(JSON_EXTRACT(rule_snapshot,'$.deadline_at')),'') AS deadline_at
FROM hg_trade_order WHERE tenant_id=? AND member_id=? AND order_type=2 AND id=? LIMIT 1 FOR UPDATE`, FROM hg_trade_order WHERE tenant_id=? AND member_id=? AND order_type=2 AND id=? LIMIT 1 FOR UPDATE`,
input.TenantID, input.MemberID, input.OrderID) input.TenantID, input.MemberID, input.OrderID)
@@ -383,8 +607,91 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
if order.OrderStatus != orderStatusPending { if order.OrderStatus != orderStatusPending {
return nil, ErrOrderState return nil, ErrOrderState
} }
var account struct {
ID int64 `db:"id"`
AvailableAmount int64 `db:"available_amount"`
FrozenAmount int64 `db:"frozen_amount"`
Status int `db:"account_status"`
Version int `db:"version"`
}
err = tx.GetContext(ctx, &account, `SELECT id,available_amount,frozen_amount,account_status,version FROM hg_wallet_account
WHERE tenant_id=? AND member_id=? AND currency='CNY' LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAccountNotFound
}
if err != nil {
return nil, err
}
if account.Status != 1 {
return nil, ErrAccountRestricted
}
snapshot := map[string]any{}
_ = json.Unmarshal(order.RuleSnapshot, &snapshot)
dispatchMode, _ := snapshot["dispatch_mode"].(string)
paymentStatus, _ := snapshot["manual_payment_status"].(string)
if dispatchMode == "manual" && paymentStatus != "paid" {
if account.AvailableAmount < order.TotalAmount {
return nil, ErrInsufficientBalance
}
holdAvailable, holdFrozen := account.AvailableAmount-order.TotalAmount, account.FrozenAmount+order.TotalAmount
walletResult, updateErr := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,
version=version+1,updated_at=UTC_TIMESTAMP(3) WHERE tenant_id=? AND id=? AND version=?`, holdAvailable,
holdFrozen, input.TenantID, account.ID, account.Version)
if updateErr != nil {
return nil, updateErr
}
if affected, _ := walletResult.RowsAffected(); affected != 1 {
return nil, ErrConcurrentUpdate
}
if _, updateErr = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
fmt.Sprintf("WL%dMA", input.OrderID), "task_principal_hold", order.OrderNo, "available", 2,
order.TotalAmount, account.AvailableAmount, holdAvailable, "match:available", "会员确认手动订单扣除可用余额"); updateErr != nil {
return nil, updateErr
}
if _, updateErr = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
fmt.Sprintf("WL%dMF", input.OrderID), "task_principal_hold", order.OrderNo, "frozen", 1,
order.TotalAmount, account.FrozenAmount, holdFrozen, "match:frozen", "会员确认手动订单冻结本金"); updateErr != nil {
return nil, updateErr
}
snapshot["manual_payment_status"], snapshot["payment_confirmed_by"] = "paid", "member"
snapshot["payment_confirmed_at"] = time.Now().UTC().Format(time.RFC3339)
rules, marshalErr := json.Marshal(snapshot)
if marshalErr != nil {
return nil, marshalErr
}
if _, updateErr = tx.ExecContext(ctx, `UPDATE hg_trade_order SET rule_snapshot=?,message='会员已确认付款,正在提交订单',updated_at=UTC_TIMESTAMP(3)
WHERE tenant_id=? AND id=? AND member_id=? AND order_status=2`, rules, input.TenantID, input.OrderID, input.MemberID); updateErr != nil {
return nil, updateErr
}
account.AvailableAmount, account.FrozenAmount, account.Version = holdAvailable, holdFrozen, account.Version+1
}
if account.FrozenAmount < order.TotalAmount {
return nil, ErrAccountRestricted
}
if order.DeadlineAt != "" { if order.DeadlineAt != "" {
if deadline, parseErr := time.Parse(time.RFC3339, order.DeadlineAt); parseErr != nil || !time.Now().UTC().Before(deadline) { if deadline, parseErr := time.Parse(time.RFC3339, order.DeadlineAt); parseErr != nil || !time.Now().UTC().Before(deadline) {
now := time.Now().UTC()
afterAvailable := account.AvailableAmount + order.TotalAmount
afterFrozen := account.FrozenAmount - order.TotalAmount
walletResult, updateErr := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,
version=version+1,updated_at=? WHERE tenant_id=? AND id=? AND version=?`, afterAvailable, afterFrozen,
now, input.TenantID, account.ID, account.Version)
if updateErr != nil {
return nil, updateErr
}
if affected, _ := walletResult.RowsAffected(); affected != 1 {
return nil, ErrConcurrentUpdate
}
if _, updateErr = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
fmt.Sprintf("WL%dEF", input.OrderID), "task_principal_release", order.OrderNo, "frozen", 2,
order.TotalAmount, account.FrozenAmount, afterFrozen, "expire:frozen", "订单超时释放冻结本金"); updateErr != nil {
return nil, updateErr
}
if _, updateErr = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
fmt.Sprintf("WL%dEA", input.OrderID), "task_principal_release", order.OrderNo, "available", 1,
order.TotalAmount, account.AvailableAmount, afterAvailable, "expire:available", "订单超时退回订单本金"); updateErr != nil {
return nil, updateErr
}
if _, updateErr := tx.ExecContext(ctx, `UPDATE hg_trade_order SET order_status=5,message='任务已超时',updated_at=UTC_TIMESTAMP(3) if _, updateErr := tx.ExecContext(ctx, `UPDATE hg_trade_order SET order_status=5,message='任务已超时',updated_at=UTC_TIMESTAMP(3)
WHERE tenant_id=? AND id=? AND order_status=2`, input.TenantID, input.OrderID); updateErr != nil { WHERE tenant_id=? AND id=? AND order_status=2`, input.TenantID, input.OrderID); updateErr != nil {
return nil, updateErr return nil, updateErr
@@ -398,24 +705,64 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
if err := validateEvidenceAttachments(ctx, tx, input.TenantID, input.MemberID, input.AttachmentIDs); err != nil { if err := validateEvidenceAttachments(ctx, tx, input.TenantID, input.MemberID, input.AttachmentIDs); err != nil {
return nil, err return nil, err
} }
if input.SubmissionNo == "" {
return nil, ErrOrderState
}
attachments, err := json.Marshal(input.AttachmentIDs) attachments, err := json.Marshal(input.AttachmentIDs)
if err != nil { if err != nil {
return nil, err return nil, err
} }
now := time.Now().UTC() now := input.SettledAt.UTC()
_, err = tx.ExecContext(ctx, `INSERT INTO hg_task_submission if now.IsZero() {
now = time.Now().UTC()
}
principalAfter := account.AvailableAmount + order.TotalAmount
afterAmount := principalAfter + order.CommissionAmount
afterFrozen := account.FrozenAmount - order.TotalAmount
accountResult, err := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,total_income=total_income+?,
version=version+1,updated_at=? WHERE tenant_id=? AND id=? AND version=?`, afterAmount, afterFrozen,
order.CommissionAmount, now, input.TenantID, account.ID, account.Version)
if err != nil {
return nil, err
}
if affected, _ := accountResult.RowsAffected(); affected != 1 {
return nil, ErrOrderState
}
if _, err = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
fmt.Sprintf("WL%dSF", input.OrderID), "task_principal_release", order.OrderNo, "frozen", 2,
order.TotalAmount, account.FrozenAmount, afterFrozen, "settle:frozen", "任务完成释放冻结本金"); err != nil {
return nil, err
}
if _, err = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
fmt.Sprintf("WL%dSA", input.OrderID), "task_principal_return", order.OrderNo, "available", 1,
order.TotalAmount, account.AvailableAmount, principalAfter, "settle:available", "任务完成返回订单本金"); err != nil {
return nil, err
}
ledgerID, err := insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
fmt.Sprintf("WL%dSC", input.OrderID), "commission", input.SubmissionNo, "available", 1,
order.CommissionAmount, principalAfter, afterAmount, "settle:commission", "任务完成自动返佣")
if err != nil {
return nil, err
}
submissionResult, err := tx.ExecContext(ctx, `INSERT INTO hg_task_submission
(tenant_id,submission_no,trade_order_id,task_id,member_id,member_level_id,evidence_text, (tenant_id,submission_no,trade_order_id,task_id,member_id,member_level_id,evidence_text,
evidence_attachment_ids,submission_status,commission_amount,success_rate,allow_resubmit,created_by,updated_by) evidence_attachment_ids,submission_status,commission_amount,success_rate,reviewed_by,reviewed_at,
VALUES(?,?,?,?,?,?,?,?,1,?,0,2,0,0)`, input.TenantID, input.SubmissionNo, input.OrderID, review_reason_code,review_reason,allow_resubmit,ledger_id,settled_at,created_by,updated_by,created_at,updated_at)
order.TaskID, input.MemberID, order.MemberLevelID, input.EvidenceText, attachments, order.CommissionAmount) VALUES(?,?,?,?,?,?,?,?,4,?,10000,0,?,'AUTO_SETTLED','会员提交后自动结算',2,?,?,0,0,?,?)`,
input.TenantID, input.SubmissionNo, input.OrderID, order.TaskID, input.MemberID, order.MemberLevelID,
input.EvidenceText, attachments, order.CommissionAmount, now, ledgerID, now, now, now)
if err != nil { if err != nil {
if isDuplicate(err) { if isDuplicate(err) {
return nil, ErrOrderState return nil, ErrOrderState
} }
return nil, err return nil, err
} }
result, err := tx.ExecContext(ctx, `UPDATE hg_trade_order SET order_status=3,message='已提交,等待审核',updated_at=UTC_TIMESTAMP(3) if affected, _ := submissionResult.RowsAffected(); affected != 1 {
WHERE tenant_id=? AND id=? AND member_id=? AND order_status=2`, input.TenantID, input.OrderID, input.MemberID) return nil, ErrOrderState
}
result, err := tx.ExecContext(ctx, `UPDATE hg_trade_order SET order_status=4,message='订单完成,佣金已入账',
completed_at=?,updated_at=?
WHERE tenant_id=? AND id=? AND member_id=? AND order_status=2`, now, now, input.TenantID, input.OrderID, input.MemberID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -425,7 +772,22 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return nil, err return nil, err
} }
return &TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: orderStatusProcessing, SubmittedAt: now}, nil return &TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: orderStatusCompleted, SubmittedAt: now}, nil
}
func insertTaskLedger(ctx context.Context, tx *sqlx.Tx, tenantID, memberID, accountID, orderID int64,
ledgerNo, bizType, bizNo, entryType string, direction int, amount, beforeAmount, afterAmount int64,
idempotencySuffix, remark string) (int64, error) {
result, err := tx.ExecContext(ctx, `INSERT INTO hg_wallet_ledger
(tenant_id,ledger_no,account_id,member_id,biz_type,biz_no,entry_type,direction,amount,
before_amount,after_amount,currency,reversal_of,idempotency_key,remark,operator_id,occurred_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,'CNY',0,?,?,?,UTC_TIMESTAMP(3))`, tenantID, ledgerNo, accountID, memberID,
bizType, bizNo, entryType, direction, amount, beforeAmount, afterAmount,
fmt.Sprintf("task:%d:%s", orderID, idempotencySuffix), remark, memberID)
if err != nil {
return 0, err
}
return result.LastInsertId()
} }
func validateEvidenceAttachments(ctx context.Context, tx *sqlx.Tx, tenantID, memberID int64, ids []int64) error { func validateEvidenceAttachments(ctx context.Context, tx *sqlx.Tx, tenantID, memberID int64, ids []int64) error {
@@ -433,7 +795,7 @@ func validateEvidenceAttachments(ctx context.Context, tx *sqlx.Tx, tenantID, mem
return nil return nil
} }
query, args, err := sqlx.In(`SELECT COUNT(*) FROM hg_sys_attachment WHERE id IN (?) query, args, err := sqlx.In(`SELECT COUNT(*) FROM hg_sys_attachment WHERE id IN (?)
AND tenant_id=? AND app_id='workbench' AND member_id=? AND status=1 AND tenant_id=? AND app_id='olons_server' AND member_id=? AND status=1
AND (mime_type LIKE 'image/%' OR mime_type='application/pdf')`, ids, tenantID, memberID) AND (mime_type LIKE 'image/%' OR mime_type='application/pdf')`, ids, tenantID, memberID)
if err != nil { if err != nil {
return err return err
@@ -453,7 +815,7 @@ func (s *Store) OrderLedgers(ctx context.Context, tenantID, memberID int64, busi
return []map[string]any{}, nil return []map[string]any{}, nil
} }
query, args, err := sqlx.In(`SELECT id,ledger_no,biz_type,biz_no,entry_type,direction,amount,before_amount,after_amount,currency,remark, query, args, err := sqlx.In(`SELECT id,ledger_no,biz_type,biz_no,entry_type,direction,amount,before_amount,after_amount,currency,remark,
DATE_FORMAT(occurred_at,'%Y-%m-%dT%H:%i:%sZ') AS occurred_at FROM hg_wallet_ledger `+walletLedgerTimeUTC()+` AS occurred_at FROM hg_wallet_ledger
WHERE tenant_id=? AND member_id=? AND biz_no IN (?) ORDER BY occurred_at,id`, tenantID, memberID, businessNumbers) WHERE tenant_id=? AND member_id=? AND biz_no IN (?) ORDER BY occurred_at,id`, tenantID, memberID, businessNumbers)
if err != nil { if err != nil {
return nil, err return nil, err
+37
View File
@@ -0,0 +1,37 @@
package store
import "testing"
func TestJSONInt(t *testing.T) {
tests := []struct {
name string
value any
want int
ok bool
}{
{name: "number", value: float64(900), want: 900, ok: true},
{name: "numeric string", value: "1200", want: 1200, ok: true},
{name: "trimmed numeric string", value: " 900 ", want: 900, ok: true},
{name: "invalid string", value: "15 minutes", ok: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, ok := jsonInt(test.value)
if got != test.want || ok != test.ok {
t.Fatalf("jsonInt(%v) = (%d, %v), want (%d, %v)", test.value, got, ok, test.want, test.ok)
}
})
}
}
func TestRandomDelaySeconds(t *testing.T) {
for index := 0; index < 100; index++ {
value := randomDelaySeconds(900, 1200)
if value < 900 || value > 1200 {
t.Fatalf("delay out of range: %d", value)
}
}
if value := randomDelaySeconds(30, 30); value != 30 {
t.Fatalf("fixed delay = %d, want 30", value)
}
}
+28
View File
@@ -0,0 +1,28 @@
package store
// databaseWallTimeUTC normalizes DATETIME values written by CURRENT_TIMESTAMP.
// The database session can use UTC or Beijing time independently of the Go process.
// Do not use it for fields explicitly written as UTC by application code.
func databaseWallTimeUTC(column string) string {
return `DATE_FORMAT(TIMESTAMPADD(SECOND,TIMESTAMPDIFF(SECOND,NOW(),UTC_TIMESTAMP()),` + column + `),'%Y-%m-%dT%H:%i:%sZ')`
}
// beijingWallTimeUTC handles DATETIME fields explicitly written by the admin
// service, whose business clock is Asia/Shanghai, independently of the DB session.
func beijingWallTimeUTC(column string) string {
return `DATE_FORMAT(TIMESTAMPADD(HOUR,-8,` + column + `),'%Y-%m-%dT%H:%i:%sZ')`
}
// walletLedgerTimeUTC preserves the UTC clock used by insertTaskLedger and
// insertWithdrawalLedger; all other ledger writers use the admin's Beijing clock.
// Legacy forced-payment entries share the task:...:match key with client entries,
// so their writer-provided remarks must also distinguish those two sources.
func walletLedgerTimeUTC() string {
return `CASE WHEN idempotency_key LIKE 'withdrawal:%'
OR idempotency_key REGEXP '^task:[0-9]+:(settle|expire):'
OR (idempotency_key REGEXP '^task:[0-9]+:match:(available|frozen)$' AND remark IN (
'自动派单扣除可用余额','自动派单冻结订单本金',
'会员确认手动订单扣除可用余额','会员确认手动订单冻结本金'))
THEN DATE_FORMAT(occurred_at,'%Y-%m-%dT%H:%i:%sZ')
ELSE ` + beijingWallTimeUTC("occurred_at") + ` END`
}
+48
View File
@@ -0,0 +1,48 @@
package store
import (
"os"
"testing"
"github.com/jmoiron/sqlx"
)
func TestOrderCreatedTimeRespectsDatabaseSessionZone(t *testing.T) {
dsn := os.Getenv("OLONS_TEST_MYSQL_DSN")
if dsn == "" {
t.Skip("set OLONS_TEST_MYSQL_DSN to run the time integration test")
}
db, err := sqlx.Connect("mysql", dsn)
if err != nil {
t.Fatal(err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
t.Cleanup(func() { _ = db.Close() })
if _, err := db.Exec(`CREATE TEMPORARY TABLE order_time_fixture (created_at DATETIME(3))`); err != nil {
t.Fatal(err)
}
for _, test := range []struct{ zone, wallTime string }{
{"+00:00", "2026-10-22 13:06:00"},
{"+08:00", "2026-10-22 21:06:00"},
} {
t.Run(test.zone, func(t *testing.T) {
if _, err := db.Exec(`SET SESSION time_zone=?`, test.zone); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`DELETE FROM order_time_fixture`); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO order_time_fixture (created_at) VALUES (?)`, test.wallTime); err != nil {
t.Fatal(err)
}
var actual string
if err := db.Get(&actual, `SELECT `+databaseWallTimeUTC("created_at")+` FROM order_time_fixture`); err != nil {
t.Fatal(err)
}
if actual != "2026-10-22T13:06:00Z" {
t.Fatalf("normalized timestamp = %q, want 2026-10-22T13:06:00Z", actual)
}
})
}
}
+20
View File
@@ -0,0 +1,20 @@
package store
import "context"
func (s *Store) UploadConfigValues(ctx context.Context) (map[string]string, error) {
rows, err := s.db.QueryContext(ctx, "SELECT `key`, `value` FROM hg_sys_config WHERE `group`='upload' AND status=1")
if err != nil {
return nil, err
}
defer rows.Close()
values := make(map[string]string)
for rows.Next() {
var key, value string
if err = rows.Scan(&key, &value); err != nil {
return nil, err
}
values[key] = value
}
return values, rows.Err()
}
+1 -1
View File
@@ -44,7 +44,7 @@ func (s *Store) WalletLedgers(ctx context.Context, tenantID, memberID int64, biz
items := make([]WalletLedger, 0) items := make([]WalletLedger, 0)
err := s.db.SelectContext(ctx, &items, `SELECT id,ledger_no,biz_type,biz_no,entry_type,direction, err := s.db.SelectContext(ctx, &items, `SELECT id,ledger_no,biz_type,biz_no,entry_type,direction,
amount,before_amount,after_amount,currency,remark, amount,before_amount,after_amount,currency,remark,
DATE_FORMAT(occurred_at,'%Y-%m-%dT%H:%i:%sZ') AS occurred_at `+walletLedgerTimeUTC()+` AS occurred_at
FROM hg_wallet_ledger`+where+` ORDER BY occurred_at DESC,id DESC LIMIT ? OFFSET ?`, args...) FROM hg_wallet_ledger`+where+` ORDER BY occurred_at DESC,id DESC LIMIT ? OFFSET ?`, args...)
return &WalletLedgerPage{Items: items, Total: total}, err return &WalletLedgerPage{Items: items, Total: total}, err
} }
+209 -88
View File
@@ -10,39 +10,53 @@ import (
) )
type PaymentAccount struct { type PaymentAccount struct {
ID int64 `db:"id"` ID int64 `db:"id"`
AccountType int `db:"account_type"` AccountType int `db:"account_type"`
BankCode string `db:"bank_code"` BankCode string `db:"bank_code"`
BankName string `db:"bank_name"` BankName string `db:"bank_name"`
HolderCipher string `db:"holder_cipher"` HolderCipher string `db:"holder_cipher"`
HolderMask string `db:"holder_mask"` HolderMask string `db:"holder_mask"`
MobileCipher string `db:"mobile_cipher"` IdentityNoCipher string `db:"identity_no_cipher"`
MobileMask string `db:"mobile_mask"` IdentityNoHash string `db:"identity_no_hash"`
AccountNoCipher string `db:"account_no_cipher"` IdentityNoMask string `db:"identity_no_mask"`
AccountNoHash string `db:"account_no_hash"` MobileCipher string `db:"mobile_cipher"`
AccountNoMask string `db:"account_no_mask"` MobileMask string `db:"mobile_mask"`
QRAttachmentID int64 `db:"qr_attachment_id"` AccountNoCipher string `db:"account_no_cipher"`
VerifyStatus int `db:"verify_status"` AccountNoHash string `db:"account_no_hash"`
IsDefault int `db:"is_default"` AccountNoMask string `db:"account_no_mask"`
CreatedAt string `db:"created_at"` DigitalRMBCodeCipher string `db:"digital_rmb_code_cipher"`
UpdatedAt string `db:"updated_at"` DigitalRMBCodeHash string `db:"digital_rmb_code_hash"`
DigitalRMBCodeMask string `db:"digital_rmb_code_mask"`
QRAttachmentID int64 `db:"qr_attachment_id"`
WechatQRAttachmentID int64 `db:"wechat_qr_attachment_id"`
AlipayQRAttachmentID int64 `db:"alipay_qr_attachment_id"`
WechatQRURL string `db:"wechat_qr_url"`
AlipayQRURL string `db:"alipay_qr_url"`
VerifyStatus int `db:"verify_status"`
IsDefault int `db:"is_default"`
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
} }
type PaymentAccountCreation struct { type PaymentAccountCreation struct {
TenantID int64 TenantID int64
MemberID int64 MemberID int64
ExpectedTradePasswordHash string AccountType int
AccountType int BankCode string
BankCode string BankName string
BankName string HolderCipher string
HolderCipher string HolderMask string
HolderMask string IdentityNoCipher string
MobileCipher string IdentityNoHash string
MobileMask string IdentityNoMask string
AccountNoCipher string AccountNoCipher string
AccountNoHash string AccountNoHash string
AccountNoMask string AccountNoMask string
QRAttachmentID int64 DigitalRMBCodeCipher string
DigitalRMBCodeHash string
DigitalRMBCodeMask string
WechatQRAttachmentID int64
AlipayQRAttachmentID int64
} }
type WithdrawalChannel struct { type WithdrawalChannel struct {
@@ -67,30 +81,34 @@ type WithdrawalWallet struct {
} }
type WithdrawalCreation struct { type WithdrawalCreation struct {
TenantID int64 TenantID int64
MemberID int64 MemberID int64
ExpectedTradePasswordHash string ExpectedTradePasswordHash string
WithdrawalNo string WithdrawalNo string
PaymentAccountID int64 PaymentAccountID int64
ExpectedAccountType int ExpectedAccountType int
ExpectedBankCode string ExpectedBankCode string
ExpectedBankName string ExpectedBankName string
ExpectedHolderCipher string ExpectedHolderCipher string
ExpectedMobileCipher string ExpectedIdentityNoCipher string
ExpectedAccountNoCipher string ExpectedIdentityNoHash string
ExpectedAccountNoHash string ExpectedAccountNoCipher string
ExpectedQRAttachmentID int64 ExpectedAccountNoHash string
Channel WithdrawalChannel ExpectedDigitalRMBCodeCipher string
RequestAmount int64 ExpectedDigitalRMBCodeHash string
FeeAmount int64 ExpectedWechatQRAttachmentID int64
ActualAmount int64 ExpectedAlipayQRAttachmentID int64
Currency string Channel WithdrawalChannel
AccountSnapshotCipher string RequestAmount int64
AccountMask string FeeAmount int64
BankName string ActualAmount int64
HolderMask string Currency string
AvailableLedgerNo string AccountSnapshotCipher string
FrozenLedgerNo string AccountMask string
BankName string
HolderMask string
AvailableLedgerNo string
FrozenLedgerNo string
} }
type WithdrawalOrder struct { type WithdrawalOrder struct {
@@ -126,8 +144,11 @@ type WithdrawalPage struct {
func (s *Store) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) { func (s *Store) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
items := make([]PaymentAccount, 0) items := make([]PaymentAccount, 0)
err := s.db.SelectContext(ctx, &items, `SELECT id,account_type,bank_code,bank_name,holder_mask,mobile_mask, err := s.db.SelectContext(ctx, &items, `SELECT id,account_type,bank_code,bank_name,holder_mask,
account_no_mask,qr_attachment_id,verify_status,is_default, COALESCE(identity_no_mask,'') AS identity_no_mask,account_no_mask,
COALESCE(digital_rmb_code_mask,'') AS digital_rmb_code_mask,
COALESCE(wechat_qr_attachment_id,0) AS wechat_qr_attachment_id,
COALESCE(alipay_qr_attachment_id,0) AS alipay_qr_attachment_id,verify_status,is_default,
DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at, DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND deleted_at IS NULL FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND deleted_at IS NULL
@@ -135,6 +156,33 @@ func (s *Store) PaymentAccounts(ctx context.Context, tenantID, memberID int64) (
return items, err return items, err
} }
// PaymentAccountDetails returns encrypted fields and their masked fallbacks for the authenticated owner.
func (s *Store) PaymentAccountDetails(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
items := make([]PaymentAccount, 0)
err := s.db.SelectContext(ctx, &items, `SELECT a.id,a.account_type,a.bank_code,a.bank_name,
COALESCE(a.holder_cipher,'') AS holder_cipher,
COALESCE(a.holder_mask,'') AS holder_mask,
COALESCE(a.identity_no_cipher,'') AS identity_no_cipher,
COALESCE(a.identity_no_mask,'') AS identity_no_mask,
COALESCE(a.account_no_cipher,'') AS account_no_cipher,
COALESCE(a.account_no_mask,'') AS account_no_mask,
COALESCE(a.digital_rmb_code_cipher,'') AS digital_rmb_code_cipher,
COALESCE(a.digital_rmb_code_mask,'') AS digital_rmb_code_mask,
COALESCE(a.wechat_qr_attachment_id,0) AS wechat_qr_attachment_id,
COALESCE(a.alipay_qr_attachment_id,0) AS alipay_qr_attachment_id,
COALESCE(wechat.file_url,'') AS wechat_qr_url,
COALESCE(alipay.file_url,'') AS alipay_qr_url,
a.verify_status,a.is_default
FROM hg_app_member_payment_account a
LEFT JOIN hg_sys_attachment wechat ON wechat.id=a.wechat_qr_attachment_id
AND wechat.member_id=a.member_id AND wechat.app_id='olons_server' AND wechat.status=1
LEFT JOIN hg_sys_attachment alipay ON alipay.id=a.alipay_qr_attachment_id
AND alipay.member_id=a.member_id AND alipay.app_id='olons_server' AND alipay.status=1
WHERE a.tenant_id=? AND a.member_id=? AND a.deleted_at IS NULL
ORDER BY a.is_default ASC,a.id DESC`, tenantID, memberID)
return items, err
}
func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCreation) (*PaymentAccount, error) { func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCreation) (*PaymentAccount, error) {
tx, err := s.db.BeginTxx(ctx, nil) tx, err := s.db.BeginTxx(ctx, nil)
if err != nil { if err != nil {
@@ -143,25 +191,22 @@ func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCr
defer tx.Rollback() defer tx.Rollback()
var memberID int64 var memberID int64
err = tx.GetContext(ctx, &memberID, `SELECT id FROM hg_app_member WHERE tenant_id=? AND id=? err = tx.GetContext(ctx, &memberID, `SELECT id FROM hg_app_member WHERE tenant_id=? AND id=?
AND trade_password_hash=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
input.TenantID, input.MemberID, input.ExpectedTradePasswordHash)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrConcurrentUpdate return nil, ErrConcurrentUpdate
} }
if err != nil { if err != nil {
return nil, err return nil, err
} }
if input.QRAttachmentID > 0 { var attachmentCount int
var attachmentCount int err = tx.GetContext(ctx, &attachmentCount, `SELECT COUNT(*) FROM hg_sys_attachment
err = tx.GetContext(ctx, &attachmentCount, `SELECT COUNT(*) FROM hg_sys_attachment WHERE id IN (?,?) AND app_id='olons_server' AND member_id=? AND status=1 AND mime_type LIKE 'image/%'`,
WHERE tenant_id=? AND id=? AND app_id='workbench' AND member_id=? AND status=1 AND mime_type LIKE 'image/%'`, input.WechatQRAttachmentID, input.AlipayQRAttachmentID, input.MemberID)
input.TenantID, input.QRAttachmentID, input.MemberID) if err != nil {
if err != nil { return nil, err
return nil, err }
} if attachmentCount != 2 {
if attachmentCount != 1 { return nil, ErrAttachmentInvalid
return nil, ErrAttachmentInvalid
}
} }
var accountCount int var accountCount int
if err = tx.GetContext(ctx, &accountCount, `SELECT COUNT(*) FROM hg_app_member_payment_account if err = tx.GetContext(ctx, &accountCount, `SELECT COUNT(*) FROM hg_app_member_payment_account
@@ -173,12 +218,17 @@ func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCr
isDefault = 1 isDefault = 1
} }
result, err := tx.ExecContext(ctx, `INSERT INTO hg_app_member_payment_account result, err := tx.ExecContext(ctx, `INSERT INTO hg_app_member_payment_account
(tenant_id,member_id,account_type,bank_code,bank_name,holder_cipher,holder_mask,mobile_cipher, (tenant_id,member_id,account_type,bank_code,bank_name,holder_cipher,holder_mask,
mobile_mask,account_no_cipher,account_no_hash,account_no_mask,qr_attachment_id,verify_status, identity_no_cipher,identity_no_hash,identity_no_mask,account_no_cipher,account_no_hash,account_no_mask,
digital_rmb_code_cipher,digital_rmb_code_hash,digital_rmb_code_mask,
wechat_qr_attachment_id,alipay_qr_attachment_id,verify_status,
is_default,created_by,updated_by) is_default,created_by,updated_by)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?)`, input.TenantID, input.MemberID, input.AccountType, VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,2,?,?,?)`, input.TenantID, input.MemberID, input.AccountType,
input.BankCode, input.BankName, input.HolderCipher, input.HolderMask, nullableString(input.MobileCipher), input.BankCode, input.BankName, input.HolderCipher, input.HolderMask,
input.MobileMask, input.AccountNoCipher, input.AccountNoHash, input.AccountNoMask, input.QRAttachmentID, input.IdentityNoCipher, input.IdentityNoHash, input.IdentityNoMask,
input.AccountNoCipher, input.AccountNoHash, input.AccountNoMask,
input.DigitalRMBCodeCipher, input.DigitalRMBCodeHash, input.DigitalRMBCodeMask,
input.WechatQRAttachmentID, input.AlipayQRAttachmentID,
isDefault, input.MemberID, input.MemberID) isDefault, input.MemberID, input.MemberID)
if err != nil { if err != nil {
if isDuplicate(err) { if isDuplicate(err) {
@@ -194,9 +244,64 @@ func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCr
return nil, err return nil, err
} }
return &PaymentAccount{ID: id, AccountType: input.AccountType, BankCode: input.BankCode, return &PaymentAccount{ID: id, AccountType: input.AccountType, BankCode: input.BankCode,
BankName: input.BankName, HolderMask: input.HolderMask, MobileMask: input.MobileMask, BankName: input.BankName, HolderMask: input.HolderMask, IdentityNoMask: input.IdentityNoMask,
AccountNoMask: input.AccountNoMask, QRAttachmentID: input.QRAttachmentID, AccountNoMask: input.AccountNoMask, DigitalRMBCodeMask: input.DigitalRMBCodeMask,
VerifyStatus: 1, IsDefault: isDefault}, nil WechatQRAttachmentID: input.WechatQRAttachmentID,
AlipayQRAttachmentID: input.AlipayQRAttachmentID,
VerifyStatus: 2, IsDefault: isDefault}, nil
}
func (s *Store) UpdatePaymentAccount(ctx context.Context, accountID int64, input PaymentAccountCreation) (*PaymentAccount, error) {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
var isDefault int
err = tx.GetContext(ctx, &isDefault, `SELECT is_default FROM hg_app_member_payment_account
WHERE id=? AND tenant_id=? AND member_id=? AND deleted_at IS NULL LIMIT 1 FOR UPDATE`,
accountID, input.TenantID, input.MemberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrPaymentAccountInvalid
}
if err != nil {
return nil, err
}
var attachmentCount int
err = tx.GetContext(ctx, &attachmentCount, `SELECT COUNT(*) FROM hg_sys_attachment
WHERE id IN (?,?) AND app_id='olons_server' AND member_id=? AND status=1 AND mime_type LIKE 'image/%'`,
input.WechatQRAttachmentID, input.AlipayQRAttachmentID, input.MemberID)
if err != nil {
return nil, err
}
if attachmentCount != 2 {
return nil, ErrAttachmentInvalid
}
_, err = tx.ExecContext(ctx, `UPDATE hg_app_member_payment_account SET
bank_name=?,holder_cipher=?,holder_mask=?,identity_no_cipher=?,identity_no_hash=?,identity_no_mask=?,
account_no_cipher=?,account_no_hash=?,account_no_mask=?,digital_rmb_code_cipher=?,
digital_rmb_code_hash=?,digital_rmb_code_mask=?,wechat_qr_attachment_id=?,alipay_qr_attachment_id=?,
verify_status=2,updated_by=?,updated_at=NOW() WHERE id=? AND tenant_id=? AND member_id=? AND deleted_at IS NULL`,
input.BankName, input.HolderCipher, input.HolderMask, input.IdentityNoCipher, input.IdentityNoHash,
input.IdentityNoMask, input.AccountNoCipher, input.AccountNoHash, input.AccountNoMask,
input.DigitalRMBCodeCipher, input.DigitalRMBCodeHash, input.DigitalRMBCodeMask,
input.WechatQRAttachmentID, input.AlipayQRAttachmentID, input.MemberID,
accountID, input.TenantID, input.MemberID)
if err != nil {
if isDuplicate(err) {
return nil, ErrPaymentAccountExists
}
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return &PaymentAccount{ID: accountID, AccountType: input.AccountType, BankCode: input.BankCode,
BankName: input.BankName, HolderMask: input.HolderMask, IdentityNoMask: input.IdentityNoMask,
AccountNoMask: input.AccountNoMask, DigitalRMBCodeMask: input.DigitalRMBCodeMask,
WechatQRAttachmentID: input.WechatQRAttachmentID,
AlipayQRAttachmentID: input.AlipayQRAttachmentID,
VerifyStatus: 2, IsDefault: isDefault}, nil
} }
func nullableString(value string) any { func nullableString(value string) any {
@@ -209,8 +314,13 @@ func nullableString(value string) any {
func (s *Store) PaymentAccountForWithdrawal(ctx context.Context, tenantID, memberID, accountID int64) (*PaymentAccount, error) { func (s *Store) PaymentAccountForWithdrawal(ctx context.Context, tenantID, memberID, accountID int64) (*PaymentAccount, error) {
var row PaymentAccount var row PaymentAccount
err := s.db.GetContext(ctx, &row, `SELECT id,account_type,bank_code,bank_name,holder_cipher,holder_mask, err := s.db.GetContext(ctx, &row, `SELECT id,account_type,bank_code,bank_name,holder_cipher,holder_mask,
COALESCE(mobile_cipher,'') AS mobile_cipher,mobile_mask,account_no_cipher,account_no_hash, COALESCE(identity_no_cipher,'') AS identity_no_cipher,COALESCE(identity_no_hash,'') AS identity_no_hash,
account_no_mask,qr_attachment_id,verify_status,is_default, COALESCE(identity_no_mask,'') AS identity_no_mask,account_no_cipher,account_no_hash,account_no_mask,
COALESCE(digital_rmb_code_cipher,'') AS digital_rmb_code_cipher,
COALESCE(digital_rmb_code_hash,'') AS digital_rmb_code_hash,
COALESCE(digital_rmb_code_mask,'') AS digital_rmb_code_mask,
COALESCE(wechat_qr_attachment_id,0) AS wechat_qr_attachment_id,
COALESCE(alipay_qr_attachment_id,0) AS alipay_qr_attachment_id,verify_status,is_default,
DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at, DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND id=? AND deleted_at IS NULL LIMIT 1`, FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND id=? AND deleted_at IS NULL LIMIT 1`,
@@ -289,8 +399,14 @@ func (s *Store) CreateWithdrawal(ctx context.Context, input WithdrawalCreation)
var account PaymentAccount var account PaymentAccount
err = tx.GetContext(ctx, &account, `SELECT id,account_type,bank_code,bank_name,holder_cipher,holder_mask, err = tx.GetContext(ctx, &account, `SELECT id,account_type,bank_code,bank_name,holder_cipher,holder_mask,
COALESCE(mobile_cipher,'') AS mobile_cipher,mobile_mask,account_no_cipher,account_no_hash, COALESCE(identity_no_cipher,'') AS identity_no_cipher,COALESCE(identity_no_hash,'') AS identity_no_hash,
account_no_mask,qr_attachment_id,verify_status,is_default,'' AS created_at,'' AS updated_at COALESCE(identity_no_mask,'') AS identity_no_mask,account_no_cipher,account_no_hash,account_no_mask,
COALESCE(digital_rmb_code_cipher,'') AS digital_rmb_code_cipher,
COALESCE(digital_rmb_code_hash,'') AS digital_rmb_code_hash,
COALESCE(digital_rmb_code_mask,'') AS digital_rmb_code_mask,
COALESCE(wechat_qr_attachment_id,0) AS wechat_qr_attachment_id,
COALESCE(alipay_qr_attachment_id,0) AS alipay_qr_attachment_id,
verify_status,is_default,'' AS created_at,'' AS updated_at
FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND id=? FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND id=?
AND verify_status=2 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID, input.PaymentAccountID) AND verify_status=2 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID, input.PaymentAccountID)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -301,8 +417,12 @@ func (s *Store) CreateWithdrawal(ctx context.Context, input WithdrawalCreation)
} }
if account.AccountType != input.ExpectedAccountType || account.BankCode != input.ExpectedBankCode || if account.AccountType != input.ExpectedAccountType || account.BankCode != input.ExpectedBankCode ||
account.BankName != input.ExpectedBankName || account.HolderCipher != input.ExpectedHolderCipher || account.BankName != input.ExpectedBankName || account.HolderCipher != input.ExpectedHolderCipher ||
account.MobileCipher != input.ExpectedMobileCipher || account.AccountNoCipher != input.ExpectedAccountNoCipher || account.IdentityNoCipher != input.ExpectedIdentityNoCipher || account.IdentityNoHash != input.ExpectedIdentityNoHash ||
account.AccountNoHash != input.ExpectedAccountNoHash || account.QRAttachmentID != input.ExpectedQRAttachmentID { account.AccountNoCipher != input.ExpectedAccountNoCipher || account.AccountNoHash != input.ExpectedAccountNoHash ||
account.DigitalRMBCodeCipher != input.ExpectedDigitalRMBCodeCipher ||
account.DigitalRMBCodeHash != input.ExpectedDigitalRMBCodeHash ||
account.WechatQRAttachmentID != input.ExpectedWechatQRAttachmentID ||
account.AlipayQRAttachmentID != input.ExpectedAlipayQRAttachmentID {
return nil, ErrConcurrentUpdate return nil, ErrConcurrentUpdate
} }
var channel WithdrawalChannel var channel WithdrawalChannel
@@ -461,11 +581,12 @@ func withdrawalSelectSQL() string {
o.request_amount,o.fee_amount,o.actual_amount,o.currency,o.account_mask,o.bank_name,o.holder_mask, o.request_amount,o.fee_amount,o.actual_amount,o.currency,o.account_mask,o.bank_name,o.holder_mask,
COALESCE(o.risk_result,JSON_OBJECT()) AS risk_result,o.withdrawal_status,o.review_reason_code, COALESCE(o.risk_result,JSON_OBJECT()) AS risk_result,o.withdrawal_status,o.review_reason_code,
o.review_reason,o.external_no, o.review_reason,o.external_no,
COALESCE(DATE_FORMAT(o.reviewed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS reviewed_at, COALESCE(` + beijingWallTimeUTC("o.reviewed_at") + `,'') AS reviewed_at,
COALESCE(DATE_FORMAT(o.processed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS processed_at, COALESCE(` + beijingWallTimeUTC("o.processed_at") + `,'') AS processed_at,
COALESCE(DATE_FORMAT(o.completed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS completed_at, COALESCE(` + beijingWallTimeUTC("o.completed_at") + `,'') AS completed_at,
DATE_FORMAT(o.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at, ` + databaseWallTimeUTC("o.created_at") + ` AS created_at,
DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at CASE WHEN o.reviewed_at IS NULL THEN ` + databaseWallTimeUTC("o.updated_at") + `
ELSE ` + beijingWallTimeUTC("o.updated_at") + ` END AS updated_at
FROM hg_withdrawal_order o LEFT JOIN hg_payment_channel c ON c.tenant_id=o.tenant_id AND c.id=o.channel_id` FROM hg_withdrawal_order o LEFT JOIN hg_payment_channel c ON c.tenant_id=o.tenant_id AND c.id=o.channel_id`
} }
+11
View File
@@ -0,0 +1,11 @@
package support
import "errors"
var (
ErrServiceNotFound = errors.New("customer service not found")
ErrServiceNotBound = errors.New("customer service IM account not bound")
ErrIMUnavailable = errors.New("IM service unavailable")
ErrRelationPending = errors.New("IM friend relationship is pending")
ErrRelationDenied = errors.New("IM friend relationship is not allowed")
)
+311
View File
@@ -0,0 +1,311 @@
package support
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
cryptorand "crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"git.118im.com/im_team/olons_server/internal/config"
"git.118im.com/im_team/olons_server/internal/imclient"
"git.118im.com/im_team/olons_server/internal/store"
)
type Repository interface {
CustomerServiceByID(context.Context, int64, int64) (*store.CustomerService, error)
MemberIMIdentityForTenant(context.Context, int64, int64, string) (*store.MemberIMIdentity, error)
MemberIMProfile(context.Context, int64, int64) (*store.MemberIMProfile, error)
SaveMemberIMIdentity(context.Context, *store.MemberIMIdentity) (*store.MemberIMIdentity, error)
}
type IM interface {
Ready() bool
Config() config.IMConfig
CreateUser(context.Context, string, string, string) (*imclient.CreatedUser, error)
FindUserByUsername(context.Context, string) (*imclient.CreatedUser, error)
LoginBasic(context.Context, string, string, string, string) (*imclient.Session, error)
ResetPassword(context.Context, string, string) error
UserProfile(context.Context, string, string) (*imclient.UserProfile, error)
AddFriendDirect(context.Context, string, string) (*imclient.DirectFriendResult, error)
EnsureP2P(context.Context, string, string, string) (*imclient.Conversation, error)
}
type Service struct {
repository Repository
im IM
aead cipher.AEAD
}
type SessionRequest struct {
DeviceID string
Platform string
}
type Agent struct {
ID int64 `json:"id"`
IMUserID string `json:"im_user_id"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
OnlineStatus string `json:"online_status"`
}
type Session struct {
TenantCode string `json:"tenant_code"`
APIBaseURL string `json:"api_base_url"`
WebSocketURL string `json:"websocket_url"`
FileURL string `json:"file_url"`
APIKey string `json:"api_key"`
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
CurrentUserID string `json:"current_user_id"`
Topic string `json:"topic"`
Relationship string `json:"relationship"`
Agent Agent `json:"agent"`
}
const (
publicIMAPIPath = "/v1/im"
publicIMWebSocketPath = "/v0/channels"
publicIMFilePath = "/v0/file"
)
func New(repository Repository, im IM, authConfig config.AuthConfig) *Service {
return &Service{repository: repository, im: im, aead: credentialAEAD(authConfig)}
}
func (s *Service) OpenSession(ctx context.Context, tenantID, memberID, serviceID int64, in SessionRequest) (*Session, error) {
if s == nil || s.repository == nil || s.im == nil || !s.im.Ready() || s.aead == nil {
return nil, ErrIMUnavailable
}
agent, err := s.repository.CustomerServiceByID(ctx, tenantID, serviceID)
if errors.Is(err, store.ErrResourceNotFound) {
return nil, ErrServiceNotFound
}
if err != nil {
return nil, err
}
cfg := s.im.Config()
if strings.TrimSpace(agent.IMUserID) == "" || !strings.EqualFold(strings.TrimSpace(agent.IMTenantCode), strings.TrimSpace(cfg.TenantCode)) {
return nil, ErrServiceNotBound
}
identity, err := s.ensureIdentity(ctx, tenantID, memberID, cfg.TenantCode)
if err != nil {
return nil, err
}
platform := strings.TrimSpace(in.Platform)
if platform == "" {
platform = "web"
}
password, err := s.decryptCredential(identity.IMPasswordCipher)
if err != nil {
return nil, fmt.Errorf("decrypt IM credential: %w", err)
}
imSession, err := s.im.LoginBasic(ctx, identity.IMUsername, password, strings.TrimSpace(in.DeviceID), platform)
if imclient.IsAuthenticationFailed(err) {
identity, err = s.resetAndSaveCredential(ctx, identity)
if err != nil {
return nil, fmt.Errorf("repair IM credential: %w", err)
}
password, err = s.decryptCredential(identity.IMPasswordCipher)
if err != nil {
return nil, fmt.Errorf("decrypt repaired IM credential: %w", err)
}
imSession, err = s.im.LoginBasic(ctx, identity.IMUsername, password, strings.TrimSpace(in.DeviceID), platform)
}
if err != nil {
return nil, fmt.Errorf("login IM session: %w", err)
}
if !strings.EqualFold(strings.TrimSpace(imSession.UserID), strings.TrimSpace(identity.IMUserID)) {
return nil, ErrIMUnavailable
}
profile, err := s.im.UserProfile(ctx, imSession.Token, agent.IMUserID)
relationship := ""
if err != nil {
if !imclient.IsPermissionDenied(err) {
return nil, fmt.Errorf("query customer service relationship: %w", err)
}
relationship = "unknown"
} else {
relationship = strings.TrimSpace(profile.Relationship)
}
switch relationship {
case "friend":
case "none", "", "pending_inbound", "pending_outbound", "pending", "unknown":
if _, err = s.im.AddFriendDirect(ctx, identity.IMUserID, agent.IMUserID); err != nil {
if imclient.IsPermissionDenied(err) || imclient.IsConflict(err) {
return nil, ErrRelationDenied
}
return nil, fmt.Errorf("add customer service as friend: %w", err)
}
default:
return nil, ErrRelationDenied
}
conversation, err := s.im.EnsureP2P(ctx, imSession.Token, agent.IMUserID,
fmt.Sprintf("p2p:%s:%s", identity.IMUserID, agent.IMUserID))
if err != nil {
return nil, fmt.Errorf("ensure customer service conversation: %w", err)
}
if strings.TrimSpace(conversation.Topic) == "" || (conversation.Relationship != "" && conversation.Relationship != "friend") || !conversation.CanSend {
return nil, ErrRelationDenied
}
return &Session{
TenantCode: cfg.TenantCode, APIBaseURL: publicIMAPIPath, WebSocketURL: publicIMWebSocketPath,
FileURL: publicIMFilePath, APIKey: cfg.APIKey, Token: imSession.Token, ExpiresAt: imSession.ExpiresAt,
CurrentUserID: imSession.UserID, Topic: conversation.Topic, Relationship: "friend",
Agent: Agent{ID: agent.ID, IMUserID: agent.IMUserID, Name: agent.Name, AvatarURL: agent.AvatarURL,
OnlineStatus: customerServiceStatus(agent.OnlineStatus)},
}, nil
}
func (s *Service) ensureIdentity(ctx context.Context, tenantID, memberID int64, imTenantCode string) (*store.MemberIMIdentity, error) {
identity, err := s.repository.MemberIMIdentityForTenant(ctx, tenantID, memberID, imTenantCode)
if err == nil {
if identity.IMUserID == "" || identity.IMUsername == "" {
return nil, ErrIMUnavailable
}
if identity.IMPasswordCipher != "" {
return identity, nil
}
return s.resetAndSaveCredential(ctx, identity)
}
if !errors.Is(err, store.ErrResourceNotFound) {
return nil, err
}
return s.createIdentity(ctx, tenantID, memberID, imTenantCode)
}
func (s *Service) createIdentity(ctx context.Context, tenantID, memberID int64, imTenantCode string) (*store.MemberIMIdentity, error) {
member, err := s.repository.MemberIMProfile(ctx, tenantID, memberID)
if err != nil {
return nil, err
}
username := memberIMUsername(tenantID, memberID, imTenantCode)
nickname := strings.TrimSpace(member.Username)
if nickname == "" {
nickname = strings.TrimSpace(member.MemberNo)
}
if nickname == "" {
nickname = "会员"
}
password, err := randomPassword()
if err != nil {
return nil, err
}
created, err := s.im.CreateUser(ctx, username, nickname, password)
if err != nil && imclient.IsConflict(err) {
created, err = s.im.FindUserByUsername(ctx, username)
if err == nil {
err = s.im.ResetPassword(ctx, string(created.UserID), password)
}
}
if err != nil {
return nil, fmt.Errorf("create IM identity: %w", err)
}
issued, err := s.im.LoginBasic(ctx, created.Username, password, "provision", "server")
if err != nil {
return nil, fmt.Errorf("login provisioned IM identity: %w", err)
}
ciphertext, err := s.encryptCredential(password)
if err != nil {
return nil, err
}
identity := &store.MemberIMIdentity{TenantID: tenantID, MemberID: memberID, IMTenantCode: imTenantCode,
IMUserID: issued.UserID, IMUsername: created.Username, IMPasswordCipher: ciphertext}
return s.repository.SaveMemberIMIdentity(ctx, identity)
}
func (s *Service) resetAndSaveCredential(ctx context.Context, identity *store.MemberIMIdentity) (*store.MemberIMIdentity, error) {
password, err := randomPassword()
if err != nil {
return nil, err
}
if err = s.im.ResetPassword(ctx, identity.IMUserID, password); err != nil {
return nil, fmt.Errorf("reset IM credential: %w", err)
}
identity.IMPasswordCipher, err = s.encryptCredential(password)
if err != nil {
return nil, err
}
return s.repository.SaveMemberIMIdentity(ctx, identity)
}
func credentialAEAD(cfg config.AuthConfig) cipher.AEAD {
var key []byte
if masterKey := strings.TrimSpace(cfg.FinanceMasterKey); len([]byte(masterKey)) >= 32 {
mac := hmac.New(sha256.New, []byte(masterKey))
_, _ = mac.Write([]byte("olons-im:credential:v1"))
key = mac.Sum(nil)
} else {
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(cfg.DataEncryptionKey))
if err != nil {
decoded, err = base64.RawStdEncoding.DecodeString(strings.TrimSpace(cfg.DataEncryptionKey))
}
if err != nil || len(decoded) != 32 {
return nil
}
key = decoded
}
block, err := aes.NewCipher(key)
if err != nil {
return nil
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil
}
return aead
}
func (s *Service) encryptCredential(value string) (string, error) {
nonce := make([]byte, s.aead.NonceSize())
if _, err := cryptorand.Read(nonce); err != nil {
return "", err
}
sealed := s.aead.Seal(nil, nonce, []byte(value), nil)
return base64.RawStdEncoding.EncodeToString(append(nonce, sealed...)), nil
}
func (s *Service) decryptCredential(value string) (string, error) {
payload, err := base64.RawStdEncoding.DecodeString(strings.TrimSpace(value))
if err != nil || len(payload) <= s.aead.NonceSize() {
return "", errors.New("encrypted IM credential is invalid")
}
plain, err := s.aead.Open(nil, payload[:s.aead.NonceSize()], payload[s.aead.NonceSize():], nil)
if err != nil {
return "", err
}
return string(plain), nil
}
// The IM service limits usernames to ten lowercase letters or digits. A short
// stable hash keeps provisioning idempotent without leaking the business ID.
func memberIMUsername(tenantID, memberID int64, imTenantCode string) string {
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%d", strings.ToLower(strings.TrimSpace(imTenantCode)), tenantID, memberID)))
return "om" + fmt.Sprintf("%x", sum[:4])
}
func randomPassword() (string, error) {
buf := make([]byte, 32)
if _, err := cryptorand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func customerServiceStatus(status int) string {
switch status {
case 1:
return "online"
case 3:
return "busy"
default:
return "offline"
}
}
+233
View File
@@ -0,0 +1,233 @@
package support
import (
"context"
"errors"
"strings"
"testing"
"time"
"git.118im.com/im_team/olons_server/internal/config"
"git.118im.com/im_team/olons_server/internal/imclient"
"git.118im.com/im_team/olons_server/internal/store"
)
type repositoryStub struct {
agent *store.CustomerService
identity *store.MemberIMIdentity
profile *store.MemberIMProfile
requestedIMTenantCode string
}
func (r *repositoryStub) CustomerServiceByID(context.Context, int64, int64) (*store.CustomerService, error) {
return r.agent, nil
}
func (r *repositoryStub) MemberIMIdentityForTenant(_ context.Context, _, _ int64, imTenantCode string) (*store.MemberIMIdentity, error) {
r.requestedIMTenantCode = imTenantCode
if r.identity == nil || !strings.EqualFold(r.identity.IMTenantCode, imTenantCode) {
return nil, store.ErrResourceNotFound
}
return r.identity, nil
}
func (r *repositoryStub) MemberIMProfile(context.Context, int64, int64) (*store.MemberIMProfile, error) {
if r.profile == nil {
return nil, store.ErrResourceNotFound
}
return r.profile, nil
}
func (r *repositoryStub) SaveMemberIMIdentity(_ context.Context, identity *store.MemberIMIdentity) (*store.MemberIMIdentity, error) {
copy := *identity
r.identity = &copy
return r.identity, nil
}
type imStub struct {
directFriendAdds int
loginAttempts int
resetPasswords int
loginError error
profileError error
friendError error
conversation *imclient.Conversation
conversationErr error
ensureP2PCalls int
}
func (i *imStub) Ready() bool { return true }
func (i *imStub) Config() config.IMConfig {
return config.IMConfig{TenantCode: "allrich", HTTPAPIURL: "https://api.118im.com/v1/im",
WebSocketURL: "wss://api.118im.com/v0/channels", FileURL: "https://api.118im.com/v0/file", APIKey: "key"}
}
func (i *imStub) CreateUser(_ context.Context, username, nickname, _ string) (*imclient.CreatedUser, error) {
return &imclient.CreatedUser{UserID: imclient.FlexibleID("1001"), Username: username, Nickname: nickname}, nil
}
func (i *imStub) FindUserByUsername(context.Context, string) (*imclient.CreatedUser, error) {
return nil, nil
}
func (i *imStub) LoginBasic(context.Context, string, string, string, string) (*imclient.Session, error) {
i.loginAttempts++
if i.loginError != nil {
err := i.loginError
i.loginError = nil
return nil, err
}
return &imclient.Session{UserID: "usr_member", Token: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
}
func (i *imStub) ResetPassword(context.Context, string, string) error {
i.resetPasswords++
return nil
}
func (i *imStub) UserProfile(context.Context, string, string) (*imclient.UserProfile, error) {
if i.profileError != nil {
return nil, i.profileError
}
return &imclient.UserProfile{UserID: "usr_agent", Relationship: "none"}, nil
}
func (i *imStub) AddFriendDirect(context.Context, string, string) (*imclient.DirectFriendResult, error) {
i.directFriendAdds++
if i.friendError != nil {
return nil, i.friendError
}
return &imclient.DirectFriendResult{Relationship: "friend", Topic: "p2p-topic"}, nil
}
func (i *imStub) EnsureP2P(context.Context, string, string, string) (*imclient.Conversation, error) {
i.ensureP2PCalls++
if i.conversationErr != nil {
return nil, i.conversationErr
}
if i.conversation != nil {
return i.conversation, nil
}
return &imclient.Conversation{Topic: "p2p-topic", Relationship: "friend", CanSend: true}, nil
}
func TestOpenSessionAutoAcceptsAndReturnsTopic(t *testing.T) {
repository := &repositoryStub{
agent: &store.CustomerService{ID: 3, Name: "客服01", IMTenantCode: "allrich", IMUserID: "usr_agent", OnlineStatus: 1},
identity: &store.MemberIMIdentity{TenantID: 0, MemberID: 9, IMTenantCode: "allrich", IMUserID: "usr_member", IMUsername: "member01"},
}
im := &imStub{}
service := New(repository, im, config.AuthConfig{FinanceMasterKey: strings.Repeat("k", 32)})
repository.identity.IMPasswordCipher, _ = service.encryptCredential("password")
session, err := service.OpenSession(context.Background(), 0, 9, 3, SessionRequest{DeviceID: "web-1", Platform: "web"})
if err != nil {
t.Fatal(err)
}
if session.Topic != "p2p-topic" || session.CurrentUserID != "usr_member" || session.Agent.IMUserID != "usr_agent" {
t.Fatalf("unexpected session: %+v", session)
}
if im.directFriendAdds != 1 {
t.Fatalf("direct friend adds = %d, want 1", im.directFriendAdds)
}
}
func TestOpenSessionReturnsSameOriginEndpoints(t *testing.T) {
repository := &repositoryStub{
agent: &store.CustomerService{ID: 3, Name: "客服01", IMTenantCode: "allrich", IMUserID: "usr_agent", OnlineStatus: 1},
identity: &store.MemberIMIdentity{TenantID: 0, MemberID: 9, IMTenantCode: "allrich", IMUserID: "usr_member", IMUsername: "member01"},
}
im := &imStub{}
service := New(repository, im, config.AuthConfig{FinanceMasterKey: strings.Repeat("k", 32)})
repository.identity.IMPasswordCipher, _ = service.encryptCredential("password")
session, err := service.OpenSession(context.Background(), 0, 9, 3, SessionRequest{DeviceID: "web-1", Platform: "web"})
if err != nil {
t.Fatal(err)
}
if session.APIBaseURL != "/v1/im" || session.WebSocketURL != "/v0/channels" || session.FileURL != "/v0/file" {
t.Fatalf("browser endpoints must be same-origin paths: %+v", session)
}
}
func TestOpenSessionDeniesWhenDirectFriendshipIsBlocked(t *testing.T) {
repository := &repositoryStub{
agent: &store.CustomerService{ID: 3, Name: "客服01", IMTenantCode: "allrich", IMUserID: "usr_agent", OnlineStatus: 1},
identity: &store.MemberIMIdentity{TenantID: 0, MemberID: 9, IMTenantCode: "allrich", IMUserID: "usr_member", IMUsername: "member01"},
}
im := &imStub{
profileError: &imclient.Error{Status: 403, Reason: "PERMISSION_DENIED"},
friendError: &imclient.Error{Status: 403, Reason: "PERMISSION_DENIED"},
conversationErr: &imclient.Error{Status: 403, Reason: "PERMISSION_DENIED"},
}
service := New(repository, im, config.AuthConfig{FinanceMasterKey: strings.Repeat("k", 32)})
repository.identity.IMPasswordCipher, _ = service.encryptCredential("password")
_, err := service.OpenSession(context.Background(), 0, 9, 3, SessionRequest{DeviceID: "web-1", Platform: "web"})
if !errors.Is(err, ErrRelationDenied) {
t.Fatalf("error = %v, want ErrRelationDenied", err)
}
if im.ensureP2PCalls != 0 || im.directFriendAdds != 1 {
t.Fatalf("ensure p2p calls=%d direct friend adds=%d, want 0 and 1", im.ensureP2PCalls, im.directFriendAdds)
}
}
func TestOpenSessionRepairsDriftedIMCredential(t *testing.T) {
repository := &repositoryStub{
agent: &store.CustomerService{ID: 3, Name: "客服01", IMTenantCode: "allrich", IMUserID: "usr_agent", OnlineStatus: 1},
identity: &store.MemberIMIdentity{TenantID: 0, MemberID: 9, IMTenantCode: "allrich", IMUserID: "usr_member", IMUsername: "member01"},
}
im := &imStub{loginError: &imclient.Error{Status: 401, Reason: "authentication failed"}}
service := New(repository, im, config.AuthConfig{FinanceMasterKey: strings.Repeat("k", 32)})
repository.identity.IMPasswordCipher, _ = service.encryptCredential("stale-password")
session, err := service.OpenSession(context.Background(), 0, 9, 3, SessionRequest{DeviceID: "web-1", Platform: "web"})
if err != nil {
t.Fatal(err)
}
if session.Topic != "p2p-topic" {
t.Fatalf("unexpected session: %+v", session)
}
if im.loginAttempts != 2 || im.resetPasswords != 1 {
t.Fatalf("login attempts=%d reset passwords=%d, want 2 and 1", im.loginAttempts, im.resetPasswords)
}
}
func TestMemberIMUsernameMatchesIMConstraint(t *testing.T) {
username := memberIMUsername(0, 9223372036854775807, "allrich")
if len(username) != 10 {
t.Fatalf("username length = %d, want 10: %s", len(username), username)
}
for _, ch := range username {
if (ch < 'a' || ch > 'z') && (ch < '0' || ch > '9') {
t.Fatalf("invalid username %q", username)
}
}
}
func TestEnsureIdentityReprovisionsMemberWhenIMTenantChanges(t *testing.T) {
repository := &repositoryStub{
identity: &store.MemberIMIdentity{TenantID: 0, MemberID: 9, IMTenantCode: "allrich", IMUserID: "usr_old", IMUsername: "olduser"},
profile: &store.MemberIMProfile{MemberID: 9, Username: "member01"},
}
im := &imStub{}
service := New(repository, im, config.AuthConfig{FinanceMasterKey: strings.Repeat("k", 32)})
identity, err := service.ensureIdentity(context.Background(), 0, 9, "default")
if err != nil {
t.Fatal(err)
}
if identity.IMTenantCode != "default" || identity.IMUserID != "usr_member" || identity.IMUsername != memberIMUsername(0, 9, "default") {
t.Fatalf("unexpected migrated identity: %+v", identity)
}
if im.loginAttempts != 1 {
t.Fatalf("provisioning login attempts = %d, want 1", im.loginAttempts)
}
}
func TestEnsureIdentityQueriesTheActiveIMTenantMapping(t *testing.T) {
repository := &repositoryStub{
identity: &store.MemberIMIdentity{TenantID: 0, MemberID: 9, IMTenantCode: "allrich", IMUserID: "usr_old", IMUsername: "olduser"},
profile: &store.MemberIMProfile{MemberID: 9, Username: "member01"},
}
im := &imStub{}
service := New(repository, im, config.AuthConfig{FinanceMasterKey: strings.Repeat("k", 32)})
identity, err := service.ensureIdentity(context.Background(), 0, 9, "default")
if err != nil {
t.Fatal(err)
}
if repository.requestedIMTenantCode != "default" {
t.Fatalf("queried IM tenant = %q, want default", repository.requestedIMTenantCode)
}
if identity.IMTenantCode != "default" {
t.Fatalf("identity tenant = %q, want default", identity.IMTenantCode)
}
}
+86 -54
View File
@@ -5,27 +5,30 @@ import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"git.118im.com/im_team/im_workbench/internal/store" "git.118im.com/im_team/olons_server/internal/store"
) )
const ( const (
defaultPageSize = 20 defaultPageSize = 20
maxPageSize = 100 maxPageSize = 100
defaultDeadlineMinute = 15 defaultDeadlineMinute = 15
minimumClaimAmount = 29900
businessDisplayStart = "09:00:00+08:00"
businessDisplayEnd = "21:00:00+08:00"
) )
var shanghai = time.FixedZone("Asia/Shanghai", 8*60*60)
type Repository interface { type Repository interface {
CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error)
TaskHalls(context.Context, int64, int64) ([]store.TaskHall, error) TaskHalls(context.Context, int64, int64) ([]store.TaskHall, error)
TaskEligibility(context.Context, int64, int64, int64) (*store.TaskEligibility, error) TaskEligibility(context.Context, int64, int64, int64) (*store.TaskEligibility, error)
ClaimTask(context.Context, store.ClaimTaskInput) (*store.TaskOrder, error) ClaimTask(context.Context, store.ClaimTaskInput) (*store.TaskOrder, error)
AutoMatchTaskOrder(context.Context, int64, int64, int64, int, int64, int64) error
TaskOrders(context.Context, int64, int64, int, int, int) (*store.TaskOrderPage, error) TaskOrders(context.Context, int64, int64, int, int, int) (*store.TaskOrderPage, error)
TaskOrder(context.Context, int64, int64, int64) (*store.TaskOrder, error) TaskOrder(context.Context, int64, int64, int64) (*store.TaskOrder, error)
TaskStats(context.Context, int64, int64) (*store.TaskStats, error) TaskStats(context.Context, int64, int64) (*store.TaskStats, error)
@@ -75,6 +78,9 @@ type Order struct {
Status string `json:"status"` Status string `json:"status"`
StatusText string `json:"status_text"` StatusText string `json:"status_text"`
Message string `json:"message"` Message string `json:"message"`
DispatchMode string `json:"dispatch_mode,omitempty"`
ScheduledMatchAt string `json:"scheduled_match_at,omitempty"`
DispatchDelay int `json:"dispatch_delay_seconds,omitempty"`
DeadlineAt string `json:"deadline_at"` DeadlineAt string `json:"deadline_at"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
@@ -150,8 +156,9 @@ type SubmitResult struct {
type taskPolicy struct { type taskPolicy struct {
Enabled bool Enabled bool
MinimumAmount int64 MinimumAmount int64
Start string AutoDelay int
End string AutoMinAmount int64
AutoMaxAmount int64
Deadline time.Duration Deadline time.Duration
} }
@@ -186,12 +193,10 @@ func (s *Service) Eligibility(ctx context.Context, tenantID, memberID, hallID in
} }
result := &Eligibility{Eligible: true, AvailableAmount: row.AvailableAmount, MinimumAmount: policy.MinimumAmount, result := &Eligibility{Eligible: true, AvailableAmount: row.AvailableAmount, MinimumAmount: policy.MinimumAmount,
TodayCount: row.TodayCount, DailyLimit: row.DailyLimit, PendingOrderID: row.PendingOrderID, TodayCount: row.TodayCount, DailyLimit: row.DailyLimit, PendingOrderID: row.PendingOrderID,
BusinessStartAt: policy.Start + "+08:00", BusinessEndAt: policy.End + "+08:00"} BusinessStartAt: businessDisplayStart, BusinessEndAt: businessDisplayEnd}
switch { switch {
case !policy.Enabled: case !policy.Enabled:
result.reject("TASK_DISABLED", "任务功能未开启") result.reject("TASK_DISABLED", "任务功能未开启")
case !withinBusinessHours(s.now().In(shanghai), policy.Start, policy.End):
result.reject("OUTSIDE_BUSINESS_HOURS", "当前不在营业时间")
case row.MemberStatus != 1: case row.MemberStatus != 1:
result.reject("ACCOUNT_RESTRICTED", "账户状态受限") result.reject("ACCOUNT_RESTRICTED", "账户状态受限")
case !row.HallEligible: case !row.HallEligible:
@@ -199,7 +204,7 @@ func (s *Service) Eligibility(ctx context.Context, tenantID, memberID, hallID in
case row.WalletStatus != 1: case row.WalletStatus != 1:
result.reject("ACCOUNT_RESTRICTED", "账户状态受限") result.reject("ACCOUNT_RESTRICTED", "账户状态受限")
case row.AvailableAmount < policy.MinimumAmount: case row.AvailableAmount < policy.MinimumAmount:
result.reject("INSUFFICIENT_BALANCE", "可用余额不足") result.reject("INSUFFICIENT_BALANCE", "可用余额不足299元")
case row.DailyLimit > 0 && row.TodayCount >= row.DailyLimit: case row.DailyLimit > 0 && row.TodayCount >= row.DailyLimit:
result.reject("DAILY_LIMIT_REACHED", "已达当日领取上限") result.reject("DAILY_LIMIT_REACHED", "已达当日领取上限")
case row.PendingOrderID > 0: case row.PendingOrderID > 0:
@@ -225,19 +230,41 @@ func (s *Service) Claim(ctx context.Context, tenantID, memberID, hallID int64) (
if !policy.Enabled { if !policy.Enabled {
return nil, ErrTaskDisabled return nil, ErrTaskDisabled
} }
if !withinBusinessHours(s.now().In(shanghai), policy.Start, policy.End) {
return nil, ErrOutsideBusinessHour
}
now := s.now().UTC() now := s.now().UTC()
row, err := s.repository.ClaimTask(ctx, store.ClaimTaskInput{TenantID: tenantID, MemberID: memberID, row, err := s.repository.ClaimTask(ctx, store.ClaimTaskInput{TenantID: tenantID, MemberID: memberID,
HallID: hallID, MinimumAmount: policy.MinimumAmount, DeadlineAt: now.Add(policy.Deadline), OrderNo: newNumber("TO", now)}) HallID: hallID, MinimumAmount: policy.MinimumAmount, AutoDelay: policy.AutoDelay,
DeadlineAt: now.Add(policy.Deadline), OrderNo: newNumber("TO", now)})
if err != nil { if err != nil {
return nil, err return nil, err
} }
result := orderFromStore(*row, false) result := orderFromStore(*row, false)
s.scheduleAutoMatch(tenantID, memberID, row.ID, row.RuleSnapshot, policy)
return &result, nil return &result, nil
} }
func (s *Service) scheduleAutoMatch(tenantID, memberID, orderID int64, raw json.RawMessage, policy taskPolicy) {
var snapshot map[string]any
if json.Unmarshal(raw, &snapshot) != nil || snapshot["dispatch_mode"] != "auto" {
return
}
matchAt, err := time.Parse(time.RFC3339, fmt.Sprint(snapshot["scheduled_match_at"]))
if err != nil {
return
}
delay := time.Until(matchAt)
if delay < 0 {
delay = 0
}
go func() {
timer := time.NewTimer(delay)
defer timer.Stop()
<-timer.C
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_ = s.repository.AutoMatchTaskOrder(ctx, tenantID, memberID, orderID, policy.AutoDelay, policy.AutoMinAmount, policy.AutoMaxAmount)
}()
}
func (s *Service) Orders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*OrderPage, error) { func (s *Service) Orders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*OrderPage, error) {
if page == 0 { if page == 0 {
page = 1 page = 1
@@ -274,6 +301,24 @@ func (s *Service) Order(ctx context.Context, tenantID, memberID, orderID int64)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if row.OrderStatus == 1 {
policy, policyErr := s.policy(ctx, tenantID)
if policyErr != nil {
return nil, policyErr
}
if policy.Enabled {
err = s.repository.AutoMatchTaskOrder(ctx, tenantID, memberID, orderID, policy.AutoDelay, policy.AutoMinAmount, policy.AutoMaxAmount)
}
if err != nil && !errors.Is(err, store.ErrNoTaskAvailable) {
return nil, err
}
if policy.Enabled && err == nil {
row, err = s.repository.TaskOrder(ctx, tenantID, memberID, orderID)
if err != nil {
return nil, err
}
}
}
ledgers, err := s.repository.OrderLedgers(ctx, tenantID, memberID, []string{row.OrderNo, row.SubmissionNo}) ledgers, err := s.repository.OrderLedgers(ctx, tenantID, memberID, []string{row.OrderNo, row.SubmissionNo})
if err != nil { if err != nil {
return nil, err return nil, err
@@ -300,17 +345,18 @@ func (s *Service) Submit(ctx context.Context, tenantID, memberID, orderID int64,
} }
input.EvidenceText = strings.TrimSpace(input.EvidenceText) input.EvidenceText = strings.TrimSpace(input.EvidenceText)
if len([]rune(input.EvidenceText)) > 2000 || len(input.EvidenceAttachmentIDs) > 9 || if len([]rune(input.EvidenceText)) > 2000 || len(input.EvidenceAttachmentIDs) > 9 ||
(input.EvidenceText == "" && len(input.EvidenceAttachmentIDs) == 0) || !validUniqueIDs(input.EvidenceAttachmentIDs) { !validUniqueIDs(input.EvidenceAttachmentIDs) {
return nil, ErrEvidenceInvalid return nil, ErrEvidenceInvalid
} }
now := s.now().UTC() now := s.now().UTC()
row, err := s.repository.SubmitTask(ctx, store.TaskSubmissionInput{TenantID: tenantID, MemberID: memberID, row, err := s.repository.SubmitTask(ctx, store.TaskSubmissionInput{TenantID: tenantID, MemberID: memberID,
OrderID: orderID, SubmissionNo: newNumber("TS", now), EvidenceText: input.EvidenceText, OrderID: orderID, SubmissionNo: newNumber("TS", now), EvidenceText: input.EvidenceText,
AttachmentIDs: input.EvidenceAttachmentIDs}) AttachmentIDs: input.EvidenceAttachmentIDs, LedgerNo: newNumber("WL", now),
IdempotencyKey: fmt.Sprintf("task:%d:auto-settle", orderID), SettledAt: now})
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &SubmitResult{SubmissionNo: row.SubmissionNo, Status: "reviewing", return &SubmitResult{SubmissionNo: row.SubmissionNo, Status: "settled",
SubmittedAt: row.SubmittedAt.Format(time.RFC3339), OrderStatus: orderStatusName(row.OrderStatus)}, nil SubmittedAt: row.SubmittedAt.Format(time.RFC3339), OrderStatus: orderStatusName(row.OrderStatus)}, nil
} }
@@ -327,11 +373,14 @@ func (s *Service) policy(ctx context.Context, tenantID int64) (taskPolicy, error
if minutes <= 0 || minutes > 24*60 { if minutes <= 0 || minutes > 24*60 {
minutes = defaultDeadlineMinute minutes = defaultDeadlineMinute
} }
start := clockValue(findValue(config, "task_business_start_at", "business_start_at", "taskBusinessStartAt"), "00:00:00") enabledValue := findValue(config, "task_enabled", "taskEnabled", "TASK")
end := clockValue(findValue(config, "task_business_end_at", "business_end_at", "taskBusinessEndAt"), "23:59:59") enabled := enabledValue == nil || boolValue(enabledValue)
return taskPolicy{Enabled: boolValue(findValue(config, "task_enabled", "taskEnabled", "TASK")), return taskPolicy{Enabled: enabled,
MinimumAmount: max64(0, int64Value(findValue(config, "task_minimum_amount", "minimum_balance_amount", "taskMinimumAmount"))), MinimumAmount: minimumClaimAmount,
Start: start, End: end, Deadline: time.Duration(minutes) * time.Minute}, nil AutoDelay: max(0, intValue(findValue(config, "auto_order_delay_seconds", "autoOrderDelaySeconds"))),
AutoMinAmount: max64(0, int64Value(findValue(config, "auto_order_min_amount", "autoOrderMinAmount"))),
AutoMaxAmount: max64(0, int64Value(findValue(config, "auto_order_max_amount", "autoOrderMaxAmount"))),
Deadline: time.Duration(minutes) * time.Minute}, nil
} }
func orderFromStore(row store.TaskOrder, detail bool) Order { func orderFromStore(row store.TaskOrder, detail bool) Order {
@@ -341,6 +390,12 @@ func orderFromStore(row store.TaskOrder, detail bool) Order {
UnitPriceAmount: row.UnitPriceAmount, TotalAmount: row.TotalAmount, CommissionAmount: row.CommissionAmount, UnitPriceAmount: row.UnitPriceAmount, TotalAmount: row.TotalAmount, CommissionAmount: row.CommissionAmount,
Currency: row.Currency, Status: status, StatusText: orderStatusText(row.OrderStatus), Message: row.Message, Currency: row.Currency, Status: status, StatusText: orderStatusText(row.OrderStatus), Message: row.Message,
DeadlineAt: row.DeadlineAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt} DeadlineAt: row.DeadlineAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
var snapshot map[string]any
if json.Unmarshal(row.RuleSnapshot, &snapshot) == nil {
result.DispatchMode, _ = snapshot["dispatch_mode"].(string)
result.ScheduledMatchAt, _ = snapshot["scheduled_match_at"].(string)
result.DispatchDelay = intValue(snapshot["dispatch_delay_seconds"])
}
if !detail { if !detail {
return result return result
} }
@@ -382,7 +437,16 @@ func orderTimeline(row store.TaskOrder) []TimelineEntry {
func allowedActions(row store.TaskOrder) []string { func allowedActions(row store.TaskOrder) []string {
switch row.OrderStatus { switch row.OrderStatus {
case 2: case 2:
return []string{"submit", "contact_support"} var snapshot map[string]any
if json.Unmarshal(row.RuleSnapshot, &snapshot) == nil {
if snapshot["balance_shortage_pending"] == true {
return []string{"contact_support"}
}
if snapshot["dispatch_mode"] == "manual" {
return []string{"submit", "contact_support"}
}
}
return []string{"contact_support"}
case 3, 5, 6, 8: case 3, 5, 6, 8:
return []string{"contact_support"} return []string{"contact_support"}
default: default:
@@ -441,38 +505,6 @@ func submissionStatusText(status int) string {
return names[status] return names[status]
} }
func withinBusinessHours(now time.Time, start, end string) bool {
current := now.Hour()*3600 + now.Minute()*60 + now.Second()
startSecond := parseClock(start)
endSecond := parseClock(end)
if startSecond <= endSecond {
return current >= startSecond && current <= endSecond
}
return current >= startSecond || current <= endSecond
}
func parseClock(value string) int {
parsed, err := time.Parse("15:04:05", value)
if err != nil {
return 0
}
return parsed.Hour()*3600 + parsed.Minute()*60 + parsed.Second()
}
func clockValue(value any, fallback string) string {
raw, ok := value.(string)
if !ok {
return fallback
}
raw = strings.TrimSpace(raw)
for _, layout := range []string{"15:04:05", "15:04"} {
if parsed, err := time.Parse(layout, raw); err == nil {
return parsed.Format("15:04:05")
}
}
return fallback
}
func findValue(config map[string]any, keys ...string) any { func findValue(config map[string]any, keys ...string) any {
for _, key := range keys { for _, key := range keys {
for candidate, value := range config { for candidate, value := range config {
+83 -19
View File
@@ -4,10 +4,11 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"strings"
"testing" "testing"
"time" "time"
"git.118im.com/im_team/im_workbench/internal/store" "git.118im.com/im_team/olons_server/internal/store"
) )
type fakeRepository struct { type fakeRepository struct {
@@ -29,9 +30,20 @@ func (f *fakeRepository) TaskEligibility(context.Context, int64, int64, int64) (
} }
func (f *fakeRepository) ClaimTask(_ context.Context, input store.ClaimTaskInput) (*store.TaskOrder, error) { func (f *fakeRepository) ClaimTask(_ context.Context, input store.ClaimTaskInput) (*store.TaskOrder, error) {
f.claimed = input f.claimed = input
return &store.TaskOrder{ID: 9, OrderNo: input.OrderNo, OrderStatus: 2, Currency: "CNY", return &store.TaskOrder{ID: 9, OrderNo: input.OrderNo, OrderStatus: 1, Currency: "CNY",
DeadlineAt: input.DeadlineAt.Format(time.RFC3339)}, nil DeadlineAt: input.DeadlineAt.Format(time.RFC3339)}, nil
} }
func (f *fakeRepository) AutoMatchTaskOrder(context.Context, int64, int64, int64, int, int64, int64) error {
if f.order != nil && f.order.OrderStatus == 1 {
f.order.OrderStatus = 2
f.order.ProductID = 7
f.order.ProductName = "自动匹配商品"
f.order.Quantity = 1
f.order.TotalAmount = 10000
f.order.CommissionAmount = 100
}
return nil
}
func (f *fakeRepository) TaskOrders(context.Context, int64, int64, int, int, int) (*store.TaskOrderPage, error) { func (f *fakeRepository) TaskOrders(context.Context, int64, int64, int, int, int) (*store.TaskOrderPage, error) {
return &store.TaskOrderPage{Items: []store.TaskOrder{*f.order}, Total: 1}, nil return &store.TaskOrderPage{Items: []store.TaskOrder{*f.order}, Total: 1}, nil
} }
@@ -43,39 +55,77 @@ func (f *fakeRepository) TaskStats(context.Context, int64, int64) (*store.TaskSt
} }
func (f *fakeRepository) SubmitTask(_ context.Context, input store.TaskSubmissionInput) (*store.TaskSubmissionResult, error) { func (f *fakeRepository) SubmitTask(_ context.Context, input store.TaskSubmissionInput) (*store.TaskSubmissionResult, error) {
f.submitted = input f.submitted = input
return &store.TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: 3, return &store.TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: 4,
SubmittedAt: time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)}, nil SubmittedAt: time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)}, nil
} }
func (f *fakeRepository) OrderLedgers(context.Context, int64, int64, []string) ([]map[string]any, error) { func (f *fakeRepository) OrderLedgers(context.Context, int64, int64, []string) ([]map[string]any, error) {
return []map[string]any{}, nil return []map[string]any{}, nil
} }
func TestEligibilityAppliesPublishedPolicy(t *testing.T) { func TestEligibilityRequiresFixedMinimumBalance(t *testing.T) {
repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":50000,"task_business_start_at":"09:00","task_business_end_at":"22:00"}`), repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":0}`),
eligibility: &store.TaskEligibility{MemberStatus: 1, HallEligible: true, AvailableAmount: 49999, WalletStatus: 1, DailyLimit: 5, TodayCount: 2}} eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 1, HallEligible: true, AvailableAmount: 29899, WalletStatus: 1, DailyLimit: 5, TodayCount: 2}}
service := New(repository) service := New(repository)
service.now = func() time.Time { return time.Date(2026, 10, 9, 10, 0, 0, 0, shanghai) }
result, err := service.Eligibility(context.Background(), 0, 10, 1) result, err := service.Eligibility(context.Background(), 0, 10, 1)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if result.Eligible || result.Reason != "INSUFFICIENT_BALANCE" || result.MinimumAmount != 50000 { if result.Eligible || result.Reason != "INSUFFICIENT_BALANCE" || result.MinimumAmount != 29900 {
t.Fatalf("unexpected eligibility: %#v", result) t.Fatalf("unexpected eligibility: %#v", result)
} }
} }
func TestClaimFailsClosedWithoutTaskSwitch(t *testing.T) { func TestEligibilityAlsoRequiresMinimumForManualDispatch(t *testing.T) {
repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true}}`),
eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 2, HallEligible: true, AvailableAmount: 29899, WalletStatus: 1, DailyLimit: 5}}
service := New(repository)
result, err := service.Eligibility(context.Background(), 0, 10, 1)
if err != nil {
t.Fatal(err)
}
if result.Eligible || result.Reason != "INSUFFICIENT_BALANCE" {
t.Fatalf("manual dispatch must use the same minimum balance gate: %#v", result)
}
}
func TestEligibilityDoesNotEnforceDisplayedBusinessHours(t *testing.T) {
repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_business_start_at":"09:00","task_business_end_at":"21:00"}`),
eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 1, HallEligible: true, AvailableAmount: 29900, WalletStatus: 1, DailyLimit: 5}}
service := New(repository)
service.now = func() time.Time { return time.Date(2026, 10, 9, 23, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
result, err := service.Eligibility(context.Background(), 0, 10, 1)
if err != nil {
t.Fatal(err)
}
if !result.Eligible || result.BusinessStartAt != "09:00:00+08:00" || result.BusinessEndAt != "21:00:00+08:00" {
t.Fatalf("business hours are display-only: %#v", result)
}
}
func TestClaimDefaultsToEnabledWithoutTaskSwitch(t *testing.T) {
repository := &fakeRepository{config: json.RawMessage(`{}`)} repository := &fakeRepository{config: json.RawMessage(`{}`)}
service := New(repository) service := New(repository)
service.now = func() time.Time { return time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC) }
if _, err := service.Claim(context.Background(), 0, 10, 1); err != nil {
t.Fatalf("missing switch should default to enabled, got %v", err)
}
}
func TestClaimHonorsExplicitDisabledTaskSwitch(t *testing.T) {
repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":false}}`)}
service := New(repository)
_, err := service.Claim(context.Background(), 0, 10, 1) _, err := service.Claim(context.Background(), 0, 10, 1)
if !errors.Is(err, ErrTaskDisabled) { if !errors.Is(err, ErrTaskDisabled) {
t.Fatalf("expected task disabled, got %v", err) t.Fatalf("expected explicitly disabled task, got %v", err)
} }
} }
func TestClaimBuildsPendingOrder(t *testing.T) { func TestClaimBuildsMatchingOrder(t *testing.T) {
repository := &fakeRepository{config: json.RawMessage(`{"task_enabled":true,"task_deadline_minutes":20}`)} repository := &fakeRepository{config: json.RawMessage(`{"task_enabled":true,"task_deadline_minutes":20}`)}
service := New(repository) service := New(repository)
now := time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC) now := time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)
@@ -85,29 +135,34 @@ func TestClaimBuildsPendingOrder(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if result.Status != "pending" || repository.claimed.HallID != 2 || if result.Status != "matching" || repository.claimed.HallID != 2 ||
!repository.claimed.DeadlineAt.Equal(now.Add(20*time.Minute)) { repository.claimed.MinimumAmount != 29900 || !repository.claimed.DeadlineAt.Equal(now.Add(20*time.Minute)) {
t.Fatalf("unexpected claim: result=%#v input=%#v", result, repository.claimed) t.Fatalf("unexpected claim: result=%#v input=%#v", result, repository.claimed)
} }
} }
func TestSubmitValidatesAndNormalizesEvidence(t *testing.T) { func TestSubmitAutomaticallySettlesWithoutEvidence(t *testing.T) {
repository := &fakeRepository{} repository := &fakeRepository{}
service := New(repository) service := New(repository)
service.now = func() time.Time { return time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC) } service.now = func() time.Time { return time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC) }
if _, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{}); !errors.Is(err, ErrEvidenceInvalid) { result, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{})
t.Fatalf("expected evidence error, got %v", err)
}
result, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{EvidenceText: " 已完成 ", EvidenceAttachmentIDs: []int64{3}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if result.Status != "reviewing" || result.OrderStatus != "processing" || repository.submitted.EvidenceText != "已完成" { if result.Status != "settled" || result.OrderStatus != "completed" || repository.submitted.IdempotencyKey != "task:9:auto-settle" || repository.submitted.LedgerNo == "" {
t.Fatalf("unexpected submission: result=%#v input=%#v", result, repository.submitted) t.Fatalf("unexpected submission: result=%#v input=%#v", result, repository.submitted)
} }
} }
func TestSubmitStillValidatesOptionalEvidence(t *testing.T) {
service := New(&fakeRepository{})
tooLong := strings.Repeat("字", 2001)
if _, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{EvidenceText: tooLong}); !errors.Is(err, ErrEvidenceInvalid) {
t.Fatalf("expected evidence error, got %v", err)
}
}
func TestOrdersRejectsUnknownStatus(t *testing.T) { func TestOrdersRejectsUnknownStatus(t *testing.T) {
service := New(&fakeRepository{}) service := New(&fakeRepository{})
_, err := service.Orders(context.Background(), 0, 10, 1, 20, "not-a-status") _, err := service.Orders(context.Background(), 0, 10, 1, 20, "not-a-status")
@@ -115,3 +170,12 @@ func TestOrdersRejectsUnknownStatus(t *testing.T) {
t.Fatalf("expected status error, got %v", err) t.Fatalf("expected status error, got %v", err)
} }
} }
func TestAllowedActionsBalancePendingOrderCannotBeSubmitted(t *testing.T) {
row := store.TaskOrder{OrderStatus: 2, RuleSnapshot: json.RawMessage(`{"dispatch_mode":"manual","balance_shortage_pending":true}`)}
for _, action := range allowedActions(row) {
if action == "submit" {
t.Fatal("余额不足待处理订单不应允许提交")
}
}
}
+61
View File
@@ -0,0 +1,61 @@
from pathlib import Path
import unittest
WORKFLOW = Path(__file__).resolve().parents[1] / ".gitea/workflows/deploy.yml"
class DeployWorkflowContractTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.workflow = WORKFLOW.read_text(encoding="utf-8")
def test_targets_pro_branch_and_single_runner(self):
self.assertIn("push:", self.workflow)
self.assertIn("- pro", self.workflow)
self.assertIn("workflow_dispatch:", self.workflow)
self.assertIn("runs-on: deploy-host", self.workflow)
self.assertIn("concurrency:", self.workflow)
def test_builds_and_pushes_pro_image(self):
self.assertIn("git.olns.shop/pro/olons_server", self.workflow)
self.assertIn("docker push", self.workflow)
self.assertIn(":pro", self.workflow)
self.assertIn("--build-arg VITE_CUSTOMER_SERVICE_PROVIDER=third_party", self.workflow)
def test_production_image_enables_customer_service_widget(self):
dockerfile = (WORKFLOW.parents[2] / "Dockerfile").read_text(encoding="utf-8")
self.assertIn("ARG VITE_CUSTOMER_SERVICE_PROVIDER=", dockerfile)
self.assertIn("ENV VITE_CUSTOMER_SERVICE_PROVIDER=${VITE_CUSTOMER_SERVICE_PROVIDER}", dockerfile)
def test_deploys_compose_service_with_pinned_host_key(self):
self.assertIn("DEPLOY_SSH_KEY", self.workflow)
self.assertIn("DEPLOY_KNOWN_HOSTS", self.workflow)
self.assertIn("StrictHostKeyChecking=yes", self.workflow)
self.assertIn("pull olons-server", self.workflow)
self.assertIn("up -d --no-deps --wait olons-server", self.workflow)
def test_removes_old_kubernetes_and_registry_targets(self):
self.assertNotIn("kubectl", self.workflow)
self.assertNotIn("KUBECONFIG", self.workflow)
self.assertNotIn("git.118im.com", self.workflow)
def test_uses_internal_im_endpoints_for_server_calls(self):
self.assertIn('"$OLONS_IM_BASE_URL" = "http://im-server:6060"', self.workflow)
self.assertIn('"$OLONS_IM_HTTP_API_URL" = "http://im-server:6060/v1/im"', self.workflow)
self.assertIn('"$OLONS_IM_WS_URL" = "ws://im-server:6060/v0/channels"', self.workflow)
self.assertIn('"$OLONS_IM_FILE_URL" = "http://im-server:6060/v0/file"', self.workflow)
self.assertNotIn("OLONS_IM_BASE_URL=https://im-api.yuexuan.shop", self.workflow)
def test_deploy_uses_managed_compose_environment_without_secret_overlay(self):
self.assertNotIn(".env.olons-server", self.workflow)
self.assertNotIn("compose.olons-server-secret.yaml", self.workflow)
self.assertNotIn("INTERNAL_API_HOTGO_SECRET", self.workflow)
self.assertNotIn("OLONS_IM_API_KEY=", self.workflow)
self.assertIn("docker compose pull olons-server", self.workflow)
self.assertIn("docker compose up -d --no-deps --wait olons-server", self.workflow)
self.assertIn('test "$OLONS_IM_TENANT_CODE" = "default"', self.workflow)
self.assertIn('test "$OLONS_SERVER_DEFAULT_TENANT_ID" = "0"', self.workflow)
if __name__ == "__main__":
unittest.main()
+6 -4
View File
@@ -9,9 +9,10 @@ class DeploymentContractTest(unittest.TestCase):
def test_workflow_targets_only_olons_server(self): def test_workflow_targets_only_olons_server(self):
workflow = (ROOT / ".gitea/workflows/deploy.yml").read_text() workflow = (ROOT / ".gitea/workflows/deploy.yml").read_text()
self.assertIn("Deploy olons-server (k3s test cluster)", workflow) self.assertIn("Deploy olons-server production", workflow)
self.assertIn("git.118im.com/im_team/olons-server:", workflow) self.assertIn("git.olns.shop/pro/olons_server", workflow)
self.assertIn("deployment/olons-server -n olons-server-system", workflow) self.assertIn("pull olons-server", workflow)
self.assertIn("up -d --no-deps --wait olons-server", workflow)
self.assertNotIn("im-workbench", workflow) self.assertNotIn("im-workbench", workflow)
self.assertNotIn("workbench-system", workflow) self.assertNotIn("workbench-system", workflow)
@@ -19,7 +20,8 @@ class DeploymentContractTest(unittest.TestCase):
dockerfile = (ROOT / "Dockerfile").read_text() dockerfile = (ROOT / "Dockerfile").read_text()
self.assertIn("RUN pnpm run build", dockerfile) self.assertIn("RUN pnpm run build", dockerfile)
self.assertIn("VITE_PUBLIC_PATH=/admin/", dockerfile) self.assertIn("ENV VITE_PUBLIC_PATH=/", dockerfile)
self.assertNotIn("VITE_PUBLIC_PATH=/admin/", dockerfile)
self.assertIn("COPY --from=web-builder /web/dist ./web/dist", dockerfile) self.assertIn("COPY --from=web-builder /web/dist ./web/dist", dockerfile)
self.assertIn("COPY --from=web-builder /web/dist /app/web/dist", dockerfile) self.assertIn("COPY --from=web-builder /web/dist /app/web/dist", dockerfile)
self.assertIn("EXPOSE 6061", dockerfile) self.assertIn("EXPOSE 6061", dockerfile)
+1 -1
View File
@@ -7,7 +7,7 @@
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/> />
<meta name="theme-color" content="#ffffff" /> <meta name="theme-color" content="#ffffff" />
<title>Olons</title> <title>悦选</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+5 -2
View File
@@ -6,12 +6,14 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@vicons/ionicons5": "^0.12.0", "@vicons/ionicons5": "^0.12.0",
"axios": "^1.7.7", "axios": "^1.7.7",
"naive-ui": "^2.44.1", "naive-ui": "^2.44.1",
"tinode-sdk": "^0.25.4",
"vue": "^3.4.38", "vue": "^3.4.38",
"vue-router": "^4.4.3" "vue-router": "^4.4.3"
}, },
@@ -19,7 +21,8 @@
"@types/node": "^22.5.2", "@types/node": "^22.5.2",
"@vitejs/plugin-vue": "^5.1.3", "@vitejs/plugin-vue": "^5.1.3",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vite": "^5.4.2" "vite": "^5.4.2",
"vitest": "^2.1.9"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
+328 -12
View File
@@ -13,10 +13,13 @@ importers:
version: 0.12.0 version: 0.12.0
axios: axios:
specifier: ^1.7.7 specifier: ^1.7.7
version: 1.7.7 version: 1.7.7(debug@4.4.3)
naive-ui: naive-ui:
specifier: ^2.44.1 specifier: ^2.44.1
version: 2.45.3(vue@3.4.38(typescript@5.5.4)) version: 2.45.3(vue@3.4.38(typescript@5.5.4))
tinode-sdk:
specifier: ^0.25.4
version: 0.25.4
vue: vue:
specifier: ^3.4.38 specifier: ^3.4.38
version: 3.4.38(typescript@5.5.4) version: 3.4.38(typescript@5.5.4)
@@ -36,6 +39,9 @@ importers:
vite: vite:
specifier: ^5.4.2 specifier: ^5.4.2
version: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6) version: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)
vitest:
specifier: ^2.1.9
version: 2.1.9(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)
packages: packages:
@@ -222,9 +228,6 @@ packages:
'@jridgewell/source-map@0.3.6': '@jridgewell/source-map@0.3.6':
resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==}
'@jridgewell/sourcemap-codec@1.5.0':
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
'@jridgewell/sourcemap-codec@1.5.5': '@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
@@ -258,46 +261,55 @@ packages:
resolution: {integrity: sha512-ztRJJMiE8nnU1YFcdbd9BcH6bGWG1z+jP+IPW2oDUAPxPjo9dverIOyXz76m6IPA6udEL12reYeLojzW2cYL7w==} resolution: {integrity: sha512-ztRJJMiE8nnU1YFcdbd9BcH6bGWG1z+jP+IPW2oDUAPxPjo9dverIOyXz76m6IPA6udEL12reYeLojzW2cYL7w==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.21.2': '@rollup/rollup-linux-arm-musleabihf@4.21.2':
resolution: {integrity: sha512-flOcGHDZajGKYpLV0JNc0VFH361M7rnV1ee+NTeC/BQQ1/0pllYcFmxpagltANYt8FYf9+kL6RSk80Ziwyhr7w==} resolution: {integrity: sha512-flOcGHDZajGKYpLV0JNc0VFH361M7rnV1ee+NTeC/BQQ1/0pllYcFmxpagltANYt8FYf9+kL6RSk80Ziwyhr7w==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.21.2': '@rollup/rollup-linux-arm64-gnu@4.21.2':
resolution: {integrity: sha512-69CF19Kp3TdMopyteO/LJbWufOzqqXzkrv4L2sP8kfMaAQ6iwky7NoXTp7bD6/irKgknDKM0P9E/1l5XxVQAhw==} resolution: {integrity: sha512-69CF19Kp3TdMopyteO/LJbWufOzqqXzkrv4L2sP8kfMaAQ6iwky7NoXTp7bD6/irKgknDKM0P9E/1l5XxVQAhw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.21.2': '@rollup/rollup-linux-arm64-musl@4.21.2':
resolution: {integrity: sha512-48pD/fJkTiHAZTnZwR0VzHrao70/4MlzJrq0ZsILjLW/Ab/1XlVUStYyGt7tdyIiVSlGZbnliqmult/QGA2O2w==} resolution: {integrity: sha512-48pD/fJkTiHAZTnZwR0VzHrao70/4MlzJrq0ZsILjLW/Ab/1XlVUStYyGt7tdyIiVSlGZbnliqmult/QGA2O2w==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-powerpc64le-gnu@4.21.2': '@rollup/rollup-linux-powerpc64le-gnu@4.21.2':
resolution: {integrity: sha512-cZdyuInj0ofc7mAQpKcPR2a2iu4YM4FQfuUzCVA2u4HI95lCwzjoPtdWjdpDKyHxI0UO82bLDoOaLfpZ/wviyQ==} resolution: {integrity: sha512-cZdyuInj0ofc7mAQpKcPR2a2iu4YM4FQfuUzCVA2u4HI95lCwzjoPtdWjdpDKyHxI0UO82bLDoOaLfpZ/wviyQ==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.21.2': '@rollup/rollup-linux-riscv64-gnu@4.21.2':
resolution: {integrity: sha512-RL56JMT6NwQ0lXIQmMIWr1SW28z4E4pOhRRNqwWZeXpRlykRIlEpSWdsgNWJbYBEWD84eocjSGDu/XxbYeCmwg==} resolution: {integrity: sha512-RL56JMT6NwQ0lXIQmMIWr1SW28z4E4pOhRRNqwWZeXpRlykRIlEpSWdsgNWJbYBEWD84eocjSGDu/XxbYeCmwg==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-s390x-gnu@4.21.2': '@rollup/rollup-linux-s390x-gnu@4.21.2':
resolution: {integrity: sha512-PMxkrWS9z38bCr3rWvDFVGD6sFeZJw4iQlhrup7ReGmfn7Oukrr/zweLhYX6v2/8J6Cep9IEA/SmjXjCmSbrMQ==} resolution: {integrity: sha512-PMxkrWS9z38bCr3rWvDFVGD6sFeZJw4iQlhrup7ReGmfn7Oukrr/zweLhYX6v2/8J6Cep9IEA/SmjXjCmSbrMQ==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.21.2': '@rollup/rollup-linux-x64-gnu@4.21.2':
resolution: {integrity: sha512-B90tYAUoLhU22olrafY3JQCFLnT3NglazdwkHyxNDYF/zAxJt5fJUB/yBoWFoIQ7SQj+KLe3iL4BhOMa9fzgpw==} resolution: {integrity: sha512-B90tYAUoLhU22olrafY3JQCFLnT3NglazdwkHyxNDYF/zAxJt5fJUB/yBoWFoIQ7SQj+KLe3iL4BhOMa9fzgpw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.21.2': '@rollup/rollup-linux-x64-musl@4.21.2':
resolution: {integrity: sha512-7twFizNXudESmC9oneLGIUmoHiiLppz/Xs5uJQ4ShvE6234K0VB1/aJYU3f/4g7PhssLGKBVCC37uRkkOi8wjg==} resolution: {integrity: sha512-7twFizNXudESmC9oneLGIUmoHiiLppz/Xs5uJQ4ShvE6234K0VB1/aJYU3f/4g7PhssLGKBVCC37uRkkOi8wjg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.21.2': '@rollup/rollup-win32-arm64-msvc@4.21.2':
resolution: {integrity: sha512-9rRero0E7qTeYf6+rFh3AErTNU1VCQg2mn7CQcI44vNUWM9Ze7MSRS/9RFuSsox+vstRt97+x3sOhEey024FRQ==} resolution: {integrity: sha512-9rRero0E7qTeYf6+rFh3AErTNU1VCQg2mn7CQcI44vNUWM9Ze7MSRS/9RFuSsox+vstRt97+x3sOhEey024FRQ==}
@@ -336,6 +348,35 @@ packages:
vite: ^5.0.0 vite: ^5.0.0
vue: ^3.2.25 vue: ^3.2.25
'@vitest/expect@2.1.9':
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
'@vitest/mocker@2.1.9':
resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@2.1.9':
resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==}
'@vitest/runner@2.1.9':
resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==}
'@vitest/snapshot@2.1.9':
resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==}
'@vitest/spy@2.1.9':
resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==}
'@vitest/utils@2.1.9':
resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
'@vue/compiler-core@3.4.38': '@vue/compiler-core@3.4.38':
resolution: {integrity: sha512-8IQOTCWnLFqfHzOGm9+P8OPSEDukgg3Huc92qSG49if/xI2SAwLHQO2qaPQbjCWPBcQoO1WYfXfTACUrWV3c5A==} resolution: {integrity: sha512-8IQOTCWnLFqfHzOGm9+P8OPSEDukgg3Huc92qSG49if/xI2SAwLHQO2qaPQbjCWPBcQoO1WYfXfTACUrWV3c5A==}
@@ -373,6 +414,10 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
async-validator@4.2.5: async-validator@4.2.5:
resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
@@ -385,6 +430,18 @@ packages:
buffer-from@1.1.2: buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
combined-stream@1.0.8: combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -412,6 +469,19 @@ packages:
date-fns@4.4.0: date-fns@4.4.0:
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
delayed-stream@1.0.0: delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
@@ -424,6 +494,9 @@ packages:
resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==}
hasBin: true hasBin: true
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
esbuild@0.21.5: esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -432,9 +505,16 @@ packages:
estree-walker@2.0.2: estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
evtd@0.2.4: evtd@0.2.4:
resolution: {integrity: sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==} resolution: {integrity: sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==}
expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
follow-redirects@1.15.6: follow-redirects@1.15.6:
resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==} resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
@@ -483,9 +563,15 @@ packages:
lodash@4.18.1: lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
magic-string@0.30.11: magic-string@0.30.11:
resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
make-dir@2.1.0: make-dir@2.1.0:
resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -503,6 +589,9 @@ packages:
engines: {node: '>=4'} engines: {node: '>=4'}
hasBin: true hasBin: true
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
naive-ui@2.45.3: naive-ui@2.45.3:
resolution: {integrity: sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw==} resolution: {integrity: sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -523,6 +612,13 @@ packages:
resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==} resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
pathe@1.1.2:
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
pathval@2.0.1:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
picocolors@1.1.0: picocolors@1.1.0:
resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==}
@@ -558,6 +654,9 @@ packages:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true hasBin: true
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
source-map-js@1.2.0: source-map-js@1.2.0:
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -569,11 +668,38 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
terser@5.31.6: terser@5.31.6:
resolution: {integrity: sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg==} resolution: {integrity: sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg==}
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
tinode-sdk@0.25.4:
resolution: {integrity: sha512-EFWWR3JDFH1oognJT3K8vH6l7TmcOqblvqx3FwiRY1RAsSUypxOzGfWMZZo0nfc7EmjK3SogRgqNWH6NxLS5+w==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
tinypool@1.1.1:
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
engines: {node: ^18.0.0 || >=20.0.0}
tinyrainbow@1.2.0:
resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==}
engines: {node: '>=14.0.0'}
tinyspy@3.0.2:
resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
engines: {node: '>=14.0.0'}
to-fast-properties@2.0.0: to-fast-properties@2.0.0:
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -597,6 +723,11 @@ packages:
peerDependencies: peerDependencies:
vue: ^3.0.11 vue: ^3.0.11
vite-node@2.1.9:
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
vite@5.4.2: vite@5.4.2:
resolution: {integrity: sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==} resolution: {integrity: sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==}
engines: {node: ^18.0.0 || >=20.0.0} engines: {node: ^18.0.0 || >=20.0.0}
@@ -628,6 +759,31 @@ packages:
terser: terser:
optional: true optional: true
vitest@2.1.9:
resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/node': ^18.0.0 || >=20.0.0
'@vitest/browser': 2.1.9
'@vitest/ui': 2.1.9
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@types/node':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
vooks@0.2.12: vooks@0.2.12:
resolution: {integrity: sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==} resolution: {integrity: sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==}
peerDependencies: peerDependencies:
@@ -651,6 +807,11 @@ packages:
peerDependencies: peerDependencies:
vue: ^3.0.11 vue: ^3.0.11
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
snapshots: snapshots:
'@babel/helper-string-parser@7.24.8': {} '@babel/helper-string-parser@7.24.8': {}
@@ -765,10 +926,7 @@ snapshots:
'@jridgewell/trace-mapping': 0.3.25 '@jridgewell/trace-mapping': 0.3.25
optional: true optional: true
'@jridgewell/sourcemap-codec@1.5.0': {} '@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/sourcemap-codec@1.5.5':
optional: true
'@jridgewell/trace-mapping@0.3.25': '@jridgewell/trace-mapping@0.3.25':
dependencies: dependencies:
@@ -845,6 +1003,46 @@ snapshots:
vite: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6) vite: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)
vue: 3.4.38(typescript@5.5.4) vue: 3.4.38(typescript@5.5.4)
'@vitest/expect@2.1.9':
dependencies:
'@vitest/spy': 2.1.9
'@vitest/utils': 2.1.9
chai: 5.3.3
tinyrainbow: 1.2.0
'@vitest/mocker@2.1.9(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6))':
dependencies:
'@vitest/spy': 2.1.9
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)
'@vitest/pretty-format@2.1.9':
dependencies:
tinyrainbow: 1.2.0
'@vitest/runner@2.1.9':
dependencies:
'@vitest/utils': 2.1.9
pathe: 1.1.2
'@vitest/snapshot@2.1.9':
dependencies:
'@vitest/pretty-format': 2.1.9
magic-string: 0.30.21
pathe: 1.1.2
'@vitest/spy@2.1.9':
dependencies:
tinyspy: 3.0.2
'@vitest/utils@2.1.9':
dependencies:
'@vitest/pretty-format': 2.1.9
loupe: 3.2.1
tinyrainbow: 1.2.0
'@vue/compiler-core@3.4.38': '@vue/compiler-core@3.4.38':
dependencies: dependencies:
'@babel/parser': 7.25.6 '@babel/parser': 7.25.6
@@ -904,13 +1102,15 @@ snapshots:
acorn@8.18.0: acorn@8.18.0:
optional: true optional: true
assertion-error@2.0.1: {}
async-validator@4.2.5: {} async-validator@4.2.5: {}
asynckit@0.4.0: {} asynckit@0.4.0: {}
axios@1.7.7: axios@1.7.7(debug@4.4.3):
dependencies: dependencies:
follow-redirects: 1.15.6 follow-redirects: 1.15.6(debug@4.4.3)
form-data: 4.0.0 form-data: 4.0.0
proxy-from-env: 1.1.0 proxy-from-env: 1.1.0
transitivePeerDependencies: transitivePeerDependencies:
@@ -919,6 +1119,18 @@ snapshots:
buffer-from@1.1.2: buffer-from@1.1.2:
optional: true optional: true
cac@6.7.14: {}
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
check-error: 2.1.3
deep-eql: 5.0.2
loupe: 3.2.1
pathval: 2.0.1
check-error@2.1.3: {}
combined-stream@1.0.8: combined-stream@1.0.8:
dependencies: dependencies:
delayed-stream: 1.0.0 delayed-stream: 1.0.0
@@ -946,6 +1158,12 @@ snapshots:
date-fns@4.4.0: {} date-fns@4.4.0: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
deep-eql@5.0.2: {}
delayed-stream@1.0.0: {} delayed-stream@1.0.0: {}
entities@4.5.0: {} entities@4.5.0: {}
@@ -955,6 +1173,8 @@ snapshots:
prr: 1.0.1 prr: 1.0.1
optional: true optional: true
es-module-lexer@1.7.0: {}
esbuild@0.21.5: esbuild@0.21.5:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5 '@esbuild/aix-ppc64': 0.21.5
@@ -983,9 +1203,17 @@ snapshots:
estree-walker@2.0.2: {} estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.5
evtd@0.2.4: {} evtd@0.2.4: {}
follow-redirects@1.15.6: {} expect-type@1.4.0: {}
follow-redirects@1.15.6(debug@4.4.3):
optionalDependencies:
debug: 4.4.3
form-data@4.0.0: form-data@4.0.0:
dependencies: dependencies:
@@ -1031,9 +1259,15 @@ snapshots:
lodash@4.18.1: {} lodash@4.18.1: {}
loupe@3.2.1: {}
magic-string@0.30.11: magic-string@0.30.11:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/sourcemap-codec': 1.5.5
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
make-dir@2.1.0: make-dir@2.1.0:
dependencies: dependencies:
@@ -1050,6 +1284,8 @@ snapshots:
mime@1.6.0: mime@1.6.0:
optional: true optional: true
ms@2.1.3: {}
naive-ui@2.45.3(vue@3.4.38(typescript@5.5.4)): naive-ui@2.45.3(vue@3.4.38(typescript@5.5.4)):
dependencies: dependencies:
'@css-render/plugin-bem': 0.15.14(css-render@0.15.14) '@css-render/plugin-bem': 0.15.14(css-render@0.15.14)
@@ -1083,6 +1319,10 @@ snapshots:
parse-node-version@1.0.1: parse-node-version@1.0.1:
optional: true optional: true
pathe@1.1.2: {}
pathval@2.0.1: {}
picocolors@1.1.0: {} picocolors@1.1.0: {}
pify@4.0.1: pify@4.0.1:
@@ -1132,6 +1372,8 @@ snapshots:
semver@5.7.2: semver@5.7.2:
optional: true optional: true
siginfo@2.0.0: {}
source-map-js@1.2.0: {} source-map-js@1.2.0: {}
source-map-support@0.5.21: source-map-support@0.5.21:
@@ -1143,6 +1385,10 @@ snapshots:
source-map@0.6.1: source-map@0.6.1:
optional: true optional: true
stackback@0.0.2: {}
std-env@3.10.0: {}
terser@5.31.6: terser@5.31.6:
dependencies: dependencies:
'@jridgewell/source-map': 0.3.6 '@jridgewell/source-map': 0.3.6
@@ -1151,6 +1397,18 @@ snapshots:
source-map-support: 0.5.21 source-map-support: 0.5.21
optional: true optional: true
tinode-sdk@0.25.4: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
tinypool@1.1.1: {}
tinyrainbow@1.2.0: {}
tinyspy@3.0.2: {}
to-fast-properties@2.0.0: {} to-fast-properties@2.0.0: {}
treemate@0.3.11: {} treemate@0.3.11: {}
@@ -1167,6 +1425,24 @@ snapshots:
evtd: 0.2.4 evtd: 0.2.4
vue: 3.4.38(typescript@5.5.4) vue: 3.4.38(typescript@5.5.4)
vite-node@2.1.9(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 1.1.2
vite: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6): vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6):
dependencies: dependencies:
esbuild: 0.21.5 esbuild: 0.21.5
@@ -1178,6 +1454,41 @@ snapshots:
less: 4.2.0 less: 4.2.0
terser: 5.31.6 terser: 5.31.6
vitest@2.1.9(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6):
dependencies:
'@vitest/expect': 2.1.9
'@vitest/mocker': 2.1.9(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6))
'@vitest/pretty-format': 2.1.9
'@vitest/runner': 2.1.9
'@vitest/snapshot': 2.1.9
'@vitest/spy': 2.1.9
'@vitest/utils': 2.1.9
chai: 5.3.3
debug: 4.4.3
expect-type: 1.4.0
magic-string: 0.30.21
pathe: 1.1.2
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinypool: 1.1.1
tinyrainbow: 1.2.0
vite: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)
vite-node: 2.1.9(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.5.2
transitivePeerDependencies:
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
vooks@0.2.12(vue@3.4.38(typescript@5.5.4)): vooks@0.2.12(vue@3.4.38(typescript@5.5.4)):
dependencies: dependencies:
evtd: 0.2.4 evtd: 0.2.4
@@ -1208,3 +1519,8 @@ snapshots:
vdirs: 0.1.8(vue@3.4.38(typescript@5.5.4)) vdirs: 0.1.8(vue@3.4.38(typescript@5.5.4))
vooks: 0.2.12(vue@3.4.38(typescript@5.5.4)) vooks: 0.2.12(vue@3.4.38(typescript@5.5.4))
vue: 3.4.38(typescript@5.5.4) vue: 3.4.38(typescript@5.5.4)
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 360" role="img" aria-label="悦选平台横幅">
<defs><linearGradient id="g" x1="0" x2="1"><stop stop-color="#1ba8ee"/><stop offset="1" stop-color="#3136dc"/></linearGradient></defs>
<rect width="1200" height="360" rx="28" fill="url(#g)"/>
<circle cx="1010" cy="70" r="180" fill="#fff" opacity=".10"/><circle cx="1120" cy="320" r="220" fill="#fff" opacity=".08"/>
<text x="90" y="160" fill="#fff" font-size="66" font-family="PingFang SC, Microsoft YaHei, sans-serif" font-weight="700">悦选</text>
<text x="92" y="230" fill="#fff" font-size="34" font-family="PingFang SC, Microsoft YaHei, sans-serif" opacity=".9">精选商品 · 安心服务</text>
</svg>

After

Width:  |  Height:  |  Size: 727 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" role="img" aria-label="悦选商品占位图">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#eaf7ff"/><stop offset="1" stop-color="#dfe3ff"/></linearGradient></defs>
<rect width="800" height="800" rx="48" fill="url(#g)"/>
<path d="M220 300h360l-28 300H248z" fill="#fff" stroke="#2878e8" stroke-width="22"/>
<path d="M310 315c0-118 180-118 180 0" fill="none" stroke="#2878e8" stroke-width="22" stroke-linecap="round"/>
<text x="400" y="690" text-anchor="middle" fill="#2878e8" font-size="54" font-family="PingFang SC, Microsoft YaHei, sans-serif" font-weight="700">悦选</text>
</svg>

After

Width:  |  Height:  |  Size: 687 B

+10
View File
@@ -1,6 +1,7 @@
<template> <template>
<main class="m-app" :class="{ 'm-app--with-nav': showNav }"> <main class="m-app" :class="{ 'm-app--with-nav': showNav }">
<router-view /> <router-view />
<SystemNoticeHost />
<nav v-if="showNav" class="m-bottom-nav" aria-label="主要导航"> <nav v-if="showNav" class="m-bottom-nav" aria-label="主要导航">
<router-link v-for="item in nav" :key="item.path" :to="item.path" :aria-label="item.label"> <router-link v-for="item in nav" :key="item.path" :to="item.path" :aria-label="item.label">
<img :src="isActive(item.path) ? item.activeIcon : item.icon" alt="" /> <img :src="isActive(item.path) ? item.activeIcon : item.icon" alt="" />
@@ -13,12 +14,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { lockHtmlScrollRightCompensationRef } from 'naive-ui/es/_utils';
import { hasSession } from './api'; import { hasSession } from './api';
import { mobileAssets } from './assets'; import { mobileAssets } from './assets';
import SystemNoticeHost from './components/SystemNoticeHost.vue';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const showNav = computed(() => Boolean(route.meta.mobileNav)); const showNav = computed(() => Boolean(route.meta.mobileNav));
const scrollbarCompensation = lockHtmlScrollRightCompensationRef;
const nav = [ const nav = [
{ {
label: '首页', label: '首页',
@@ -71,3 +75,9 @@
watch(() => route.path, ensureAccess, { immediate: true }); watch(() => route.path, ensureAccess, { immediate: true });
</script> </script>
<style scoped>
.m-bottom-nav {
right: v-bind(scrollbarCompensation);
}
</style>
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { dateTime, dateTimeSeconds } from './api';
describe('Beijing business time display', () => {
it.each(['UTC', 'America/New_York', 'Asia/Shanghai'])(
'displays the same Beijing time on a device using %s',
(deviceTimeZone) => {
const previous = process.env.TZ;
process.env.TZ = deviceTimeZone;
try {
expect(dateTime('2026-10-22T13:06:00Z')).toBe('2026-10-22 21:06');
expect(dateTime('2026-10-22T21:06:00+08:00')).toBe('2026-10-22 21:06');
expect(dateTime('2026-10-22T16:00:00Z')).toBe('2026-10-23 00:00');
expect(dateTimeSeconds('2026-10-22T13:06:07Z')).toBe('2026-10-22 21:06:07');
expect(dateTimeSeconds('2026-10-22T21:06:07+08:00')).toBe('2026-10-22 21:06:07');
expect(dateTimeSeconds('2026-10-22T16:00:07Z')).toBe('2026-10-23 00:00:07');
} finally {
if (previous === undefined) delete process.env.TZ;
else process.env.TZ = previous;
}
}
);
it('uses a placeholder for missing or invalid timestamps', () => {
expect(dateTime()).toBe('--');
expect(dateTime('invalid-time')).toBe('--');
expect(dateTimeSeconds()).toBe('--');
expect(dateTimeSeconds('invalid-time')).toBe('--');
});
});
+36 -2
View File
@@ -2,16 +2,18 @@ import axios, { AxiosError, AxiosRequestConfig } from 'axios';
const TOKEN_KEY = 'olons_web_session'; const TOKEN_KEY = 'olons_web_session';
const DEVICE_KEY = 'olons_web_device'; const DEVICE_KEY = 'olons_web_device';
const LOGIN_NOTICE_PENDING_KEY = 'olons_login_notice_pending';
const reasonMessages: Record<string, string> = { const reasonMessages: Record<string, string> = {
AUTH_REQUIRED: '登录已失效,请重新登录', AUTH_REQUIRED: '登录已失效,请重新登录',
FORBIDDEN: '当前账户无权执行此操作', FORBIDDEN: '当前账户无权执行此操作',
INVALID_CREDENTIALS: '手机号或密码错误', INVALID_CREDENTIALS: '手机号或密码错误',
ACCOUNT_ALREADY_REGISTERED: '该账户或手机号已经注册', ACCOUNT_ALREADY_REGISTERED: '该账户或手机号已经注册',
REFERRAL_CODE_INVALID: '邀请码无效,请检查后重新输入',
RATE_LIMITED: '操作过于频繁,请稍后再试', RATE_LIMITED: '操作过于频繁,请稍后再试',
TASK_DISABLED: '领取功能暂未开放', TASK_DISABLED: '领取功能暂未开放',
OUTSIDE_BUSINESS_HOURS: '当前不在营业时间内', OUTSIDE_BUSINESS_HOURS: '当前不在营业时间内',
INSUFFICIENT_BALANCE: '可用余额不足', INSUFFICIENT_BALANCE: '可用余额不足299元,暂时无法领取',
DAILY_LIMIT_REACHED: '今日领取次数已用完', DAILY_LIMIT_REACHED: '今日领取次数已用完',
PENDING_ORDER_EXISTS: '存在未完成的任务,请先处理', PENDING_ORDER_EXISTS: '存在未完成的任务,请先处理',
NO_TASK_AVAILABLE: '当前暂无可领取任务', NO_TASK_AVAILABLE: '当前暂无可领取任务',
@@ -19,6 +21,9 @@ const reasonMessages: Record<string, string> = {
ORDER_STATE_INVALID: '当前任务状态不允许此操作', ORDER_STATE_INVALID: '当前任务状态不允许此操作',
TRADE_PASSWORD_NOT_SET: '请先设置支付密码', TRADE_PASSWORD_NOT_SET: '请先设置支付密码',
TRADE_PASSWORD_INVALID: '支付密码不正确', TRADE_PASSWORD_INVALID: '支付密码不正确',
PAYMENT_ACCOUNT_FIELDS_INVALID: '绑卡信息填写不完整或格式不正确',
QR_ATTACHMENT_INVALID: '二维码图片无效,请重新上传',
IMAGE_INVALID: '图片格式或大小不符合要求',
PAYMENT_ACCOUNT_INVALID: '收款账户未核验或不可用', PAYMENT_ACCOUNT_INVALID: '收款账户未核验或不可用',
WITHDRAWAL_DISABLED: '取出功能暂未开放', WITHDRAWAL_DISABLED: '取出功能暂未开放',
WITHDRAWAL_AMOUNT_INVALID: '取出金额不符合当前规则', WITHDRAWAL_AMOUNT_INVALID: '取出金额不符合当前规则',
@@ -28,6 +33,11 @@ const reasonMessages: Record<string, string> = {
RECHARGE_CHANNEL_UNAVAILABLE: '当前没有可用的储入通道', RECHARGE_CHANNEL_UNAVAILABLE: '当前没有可用的储入通道',
RECHARGE_VOUCHER_INVALID: '储入凭证无效', RECHARGE_VOUCHER_INVALID: '储入凭证无效',
SERVICE_UNAVAILABLE: '服务暂不可用,请稍后重试', SERVICE_UNAVAILABLE: '服务暂不可用,请稍后重试',
CUSTOMER_SERVICE_NOT_FOUND: '客服已下线或不存在',
CUSTOMER_SERVICE_NOT_BOUND: '客服账号尚未配置',
IM_RELATION_PENDING: '好友申请已发送,请等待客服确认',
IM_RELATION_NOT_ALLOWED: '当前无法与该客服建立会话',
IM_SERVICE_UNAVAILABLE: '客服系统暂不可用,请稍后重试',
INTERNAL: '服务异常,请稍后重试', INTERNAL: '服务异常,请稍后重试',
}; };
@@ -130,18 +140,32 @@ function getDeviceID() {
return value; return value;
} }
export function deviceID() {
return getDeviceID();
}
export function saveSession(token: string) { export function saveSession(token: string) {
sessionStorage.setItem(TOKEN_KEY, token); sessionStorage.setItem(TOKEN_KEY, token);
sessionStorage.setItem(LOGIN_NOTICE_PENDING_KEY, '1');
} }
export function clearSession() { export function clearSession() {
sessionStorage.removeItem(TOKEN_KEY); sessionStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(LOGIN_NOTICE_PENDING_KEY);
} }
export function hasSession() { export function hasSession() {
return Boolean(sessionStorage.getItem(TOKEN_KEY)); return Boolean(sessionStorage.getItem(TOKEN_KEY));
} }
export function shouldShowLoginNotice() {
return sessionStorage.getItem(LOGIN_NOTICE_PENDING_KEY) !== '0';
}
export function completeLoginNotice() {
sessionStorage.setItem(LOGIN_NOTICE_PENDING_KEY, '0');
}
export function idempotencyKey() { export function idempotencyKey() {
return crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`; return crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
} }
@@ -156,16 +180,26 @@ export function money(value?: number, currency = 'CNY') {
} }
export function dateTime(value?: string) { export function dateTime(value?: string) {
return formatBeijingDateTime(value, false);
}
export function dateTimeSeconds(value?: string) {
return formatBeijingDateTime(value, true);
}
function formatBeijingDateTime(value: string | undefined, withSeconds: boolean) {
if (!value) return '--'; if (!value) return '--';
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return '--'; if (Number.isNaN(date.getTime())) return '--';
return new Intl.DateTimeFormat('zh-CN', { return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
hour12: false, ...(withSeconds ? { second: '2-digit' as const } : {}),
hourCycle: 'h23',
}) })
.format(date) .format(date)
.replaceAll('/', '-'); .replaceAll('/', '-');
-2
View File
@@ -31,7 +31,6 @@ import processSimple from './assets/process-simple.png';
import processMaterial from './assets/process-material.png'; import processMaterial from './assets/process-material.png';
import processTime from './assets/process-time.png'; import processTime from './assets/process-time.png';
import processReturn from './assets/process-return.png'; import processReturn from './assets/process-return.png';
import chatVoice from './assets/chat-voice.png';
import chatMore from './assets/chat-more.png'; import chatMore from './assets/chat-more.png';
import companyPlatform from './assets/company-platform.png'; import companyPlatform from './assets/company-platform.png';
import companyRules from './assets/company-rules.png'; import companyRules from './assets/company-rules.png';
@@ -108,7 +107,6 @@ export const mobileAssets = {
processMaterial, processMaterial,
processTime, processTime,
processReturn, processReturn,
chatVoice,
chatMore, chatMore,
companyPlatform, companyPlatform,
companyRules, companyRules,
Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -0,0 +1,124 @@
<template>
<n-modal :show="show" :mask-closable="false" :close-on-esc="false" :z-index="4000">
<section
class="m-announcement-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="announcement-title"
aria-describedby="announcement-content"
tabindex="-1"
>
<h2 id="announcement-title" class="m-announcement-sr-title">系统通知</h2>
<img class="m-announcement-board" :src="announcementBoard" alt="" />
<div class="m-announcement-body">
<div id="announcement-content" class="m-announcement-content">
<h3
v-if="notice && notice.id > 0 && notice.title && notice.title !== '系统通知'"
class="m-announcement-subtitle"
>{{ notice.title }}</h3>
<div v-if="notice?.body_html" v-html="notice.body_html"></div>
<template v-else>{{ notice?.content }}</template>
</div>
<button type="button" :disabled="reading" @click="$emit('read')">
{{ reading ? '正在确认…' : '我已阅读' }}
</button>
</div>
</section>
</n-modal>
</template>
<script setup lang="ts">
import announcementBoard from '../assets/system-notice-popup@2x.png';
export interface AnnouncementNotice {
id: number;
title: string;
content: string;
body_html?: string;
source_type?: string;
}
defineProps<{
show: boolean;
notice?: AnnouncementNotice | null;
reading?: boolean;
}>();
defineEmits<{ read: [] }>();
</script>
<style scoped>
.m-announcement-dialog {
position: relative;
width: min(312px, calc(100vw - 32px), calc((100vh - 32px) * 2 / 3));
aspect-ratio: 2 / 3;
background: transparent;
box-shadow: none;
}
.m-announcement-board {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: fill;
pointer-events: none;
user-select: none;
}
.m-announcement-body {
position: absolute;
inset: 31.2% 16.7% 13.6%;
display: flex;
min-height: 0;
flex-direction: column;
}
.m-announcement-content {
flex: 1;
min-height: 0;
overflow-y: auto;
color: #282828;
font-size: clamp(14px, 4.25vw, 16px);
line-height: 1.42;
text-align: left;
white-space: pre-line;
scrollbar-width: none;
}
.m-announcement-content::-webkit-scrollbar {
display: none;
}
.m-announcement-subtitle {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
}
.m-announcement-body button {
flex: none;
width: 100%;
height: 41px;
margin-top: 10px;
border: 0;
border-radius: 24px;
background: linear-gradient(90deg, #ff972c 0%, #f45119 100%);
box-shadow: 0 5px 12px rgba(244, 81, 25, 0.2);
color: #fff;
font-size: 16px;
cursor: pointer;
}
.m-announcement-sr-title {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
@@ -0,0 +1,100 @@
<template>
<AnnouncementPopup
:show="Boolean(activeSystemNotice)"
:notice="activeSystemNotice"
:reading="reading"
@read="markRead"
/>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import AnnouncementPopup, { type AnnouncementNotice } from './AnnouncementPopup.vue';
import {
completeLoginNotice,
errorText,
hasSession,
MobileApiError,
request,
shouldShowLoginNotice,
} from '../api';
import { activeSystemNotice, closeSystemNotice, openSystemNotice } from '../systemNotices';
const route = useRoute();
const reading = ref(false);
let timer: number | undefined;
let refreshing = false;
let disposed = false;
const controller = new AbortController();
async function refresh() {
if (disposed || refreshing || activeSystemNotice.value || !hasSession() ||
document.visibilityState === 'hidden') return;
refreshing = true;
let loadingLoginNotice = false;
try {
const pending = await request<AnnouncementNotice | null>({
url: '/v1/notices/pending', signal: controller.signal,
});
if (disposed || activeSystemNotice.value) return;
if (pending) {
openSystemNotice(pending);
return;
}
if (shouldShowLoginNotice()) {
loadingLoginNotice = true;
const loginNotice = await request<AnnouncementNotice | null>({
url: '/v1/notices/popup', signal: controller.signal,
});
if (disposed || activeSystemNotice.value) return;
if (loginNotice) openSystemNotice(loginNotice);
else completeLoginNotice();
}
} catch (error) {
if (loadingLoginNotice && error instanceof MobileApiError && error.status === 404) completeLoginNotice();
// Leave the page usable and retry on the next poll or when the page becomes visible.
} finally {
refreshing = false;
}
}
async function markRead() {
const notice = activeSystemNotice.value;
if (!notice || reading.value) return;
reading.value = true;
try {
if (notice.id > 0) {
await request({
url: `/v1/notices/${notice.id}/read`, method: 'POST', signal: controller.signal,
});
} else {
completeLoginNotice();
}
closeSystemNotice();
} catch (error) {
if (error instanceof MobileApiError && error.status === 404) closeSystemNotice();
else if (!disposed) window.$message.error(errorText(error));
} finally {
reading.value = false;
}
if (!disposed && !activeSystemNotice.value) void refresh();
}
const resume = () => void refresh();
watch(() => route.fullPath, resume);
onMounted(() => {
void refresh();
timer = window.setInterval(resume, 15000);
document.addEventListener('visibilitychange', resume);
window.addEventListener('online', resume);
});
onBeforeUnmount(() => {
disposed = true;
controller.abort();
if (timer) window.clearInterval(timer);
document.removeEventListener('visibilitychange', resume);
window.removeEventListener('online', resume);
closeSystemNotice();
});
</script>
+306
View File
@@ -0,0 +1,306 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({ instances: [] as any[] }));
vi.mock('tinode-sdk', () => {
class QueryBuilder {
private kind = '';
private since?: number;
private limit?: number;
constructor(private topic?: { maxMsgSeq?: () => number }) {}
withData(_since?: number, _before?: number, limit?: number) {
this.kind = 'initial';
this.limit = limit;
return this;
}
withLaterData(limit?: number) {
this.kind = 'later';
this.since = (this.topic?.maxMsgSeq?.() || 0) + 1;
this.limit = limit;
return this;
}
build() {
return { kind: this.kind, since: this.since, limit: this.limit };
}
}
class MockTinode {
onConnect?: () => Promise<void> | void;
onDisconnect?: () => void;
onAutoreconnectIteration?: (timeoutMs: number) => void;
_connection = { sendText: vi.fn() };
connect = vi.fn(async () => undefined);
loginToken = vi.fn(async () => ({ code: 200 }));
disconnect = vi.fn();
authorizeURL = vi.fn((value: string) => value);
topic = {
onData: undefined as (() => void) | undefined,
onAllMessagesReceived: undefined as ((count: number) => void) | undefined,
maxSeq: 0,
maxMsgSeq: vi.fn(() => this.topic.maxSeq),
startMetaQuery: () => new QueryBuilder(this.topic),
subscribe: vi.fn(async () => {
setTimeout(() => this.topic.onAllMessagesReceived?.(0), 0);
return { code: 200, params: { what: 'data', count: 0 } };
}),
getMeta: vi.fn(async () => ({ code: 200, params: { what: 'data', count: 0 } })),
leave: vi.fn(),
messages: vi.fn(),
createMessage: vi.fn(),
publishDraft: vi.fn(),
};
constructor(public options: Record<string, unknown>) {
mocks.instances.push(this);
}
getTopic() {
return this.topic;
}
}
return {
Tinode: MockTinode,
Drafty: { appendImage: vi.fn(), insertVideo: vi.fn() },
};
});
import {
CustomerServiceChat,
resolveCustomerServiceEndpoints,
type CustomerServiceSession,
} from './im';
const session = (overrides: Partial<CustomerServiceSession> = {}): CustomerServiceSession => ({
tenant_code: 'default',
api_base_url: '/v1/im',
file_url: '/v0/file',
websocket_url: '/v0/channels',
api_key: 'public-key',
token: 'token-1',
current_user_id: 'usr_member',
topic: 'p2p-topic',
...overrides,
});
describe('customer service IM endpoints', () => {
it('resolves API, file and websocket paths against the current page origin', () => {
expect(resolveCustomerServiceEndpoints(session(), 'https://www.yuexuan2.shop')).toEqual({
apiBaseURL: 'https://www.yuexuan2.shop/v1/im',
fileURL: 'https://www.yuexuan2.shop/v0/file',
websocketURL: 'wss://www.yuexuan2.shop/v0/channels',
});
});
it('does not follow legacy absolute IM hosts from a cached session response', () => {
expect(
resolveCustomerServiceEndpoints(
session({
api_base_url: 'https://im-api.yuexuan.shop/v1/im',
file_url: 'https://im-api.yuexuan.shop/v0/file',
websocket_url: 'wss://im-api.yuexuan.shop/v0/channels',
}),
'https://www.yuexuan3.shop',
),
).toEqual({
apiBaseURL: 'https://www.yuexuan3.shop/v1/im',
fileURL: 'https://www.yuexuan3.shop/v0/file',
websocketURL: 'wss://www.yuexuan3.shop/v0/channels',
});
});
});
describe('customer service reconnect', () => {
beforeEach(() => {
mocks.instances.length = 0;
});
it('reauthenticates and resubscribes with later data after reconnect', async () => {
const states: string[] = [];
const chat = new CustomerServiceChat(session(), vi.fn(), {
origin: 'https://www.yuexuan1.shop',
onConnectionState: (state) => states.push(state),
});
const tinode = mocks.instances[0];
await chat.connect('token-1');
tinode.onDisconnect?.();
await tinode.onConnect?.();
expect(tinode.loginToken).toHaveBeenCalledTimes(2);
expect(tinode.topic.subscribe).toHaveBeenCalledTimes(2);
expect(tinode.topic.subscribe.mock.calls[0][0]).toEqual({ kind: 'initial', limit: 50, since: undefined });
expect(tinode.topic.subscribe.mock.calls[1][0]).toEqual({ kind: 'later', limit: 50, since: 1 });
expect(states).toEqual(expect.arrayContaining(['connecting', 'online', 'reconnecting']));
expect(states.at(-1)).toBe('online');
});
it('does not restore a deliberately disconnected chat', async () => {
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://olons.yuexuan.shop' });
const tinode = mocks.instances[0];
await chat.connect('token-1');
chat.disconnect();
await tinode.onConnect?.();
expect(tinode.loginToken).toHaveBeenCalledTimes(1);
expect(tinode.topic.subscribe).toHaveBeenCalledTimes(1);
});
it('reports offline without leaking a rejected reconnect callback', async () => {
const states: string[] = [];
const chat = new CustomerServiceChat(session(), vi.fn(), {
origin: 'https://www.yuexuan3.shop',
onConnectionState: (state) => states.push(state),
});
const tinode = mocks.instances[0];
await chat.connect('token-1');
tinode.loginToken.mockRejectedValueOnce(new Error('network unavailable'));
tinode.onDisconnect?.();
await expect(tinode.onConnect?.()).resolves.toBeUndefined();
expect(states.at(-1)).toBe('offline');
});
it('uses rotated login tokens for reconnect and media upload', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ ctrl: { params: { url: '/v0/file/s/file-1' } } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan1.shop' });
const tinode = mocks.instances[0];
tinode.loginToken
.mockResolvedValueOnce({ code: 200, params: { token: 'token-2' } })
.mockResolvedValueOnce({ code: 200, params: { token: 'token-3' } });
await chat.connect('token-1');
await tinode.onConnect?.();
await (chat as any).upload(new File(['image'], 'test.png', { type: 'image/png' }));
expect(tinode.loginToken.mock.calls.map((call: unknown[]) => call[0])).toEqual(['token-1', 'token-2']);
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ Authorization: 'Token token-3' });
fetchMock.mockRestore();
});
it('paginates all messages accumulated while disconnected', async () => {
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan1.shop' });
const tinode = mocks.instances[0];
await chat.connect('token-1');
tinode.topic.subscribe.mockImplementationOnce(async () => {
setTimeout(() => {
tinode.topic.maxSeq = 50;
tinode.topic.onAllMessagesReceived?.(50);
}, 10);
return { code: 200, params: { what: 'data', count: 50 } };
});
tinode.topic.getMeta
.mockImplementationOnce(async () => {
setTimeout(() => {
tinode.topic.maxSeq = 100;
tinode.topic.onAllMessagesReceived?.(50);
}, 10);
return { code: 200, params: { what: 'data', count: 50 } };
})
.mockImplementationOnce(async () => {
setTimeout(() => {
tinode.topic.maxSeq = 120;
tinode.topic.onAllMessagesReceived?.(20);
}, 10);
return { code: 200, params: { what: 'data', count: 20 } };
});
await tinode.onConnect?.();
expect(tinode.topic.getMeta).toHaveBeenCalledTimes(2);
expect(tinode.topic.getMeta.mock.calls.map((call: unknown[]) => call[0])).toEqual([
{ kind: 'later', limit: 50, since: 51 },
{ kind: 'later', limit: 50, since: 101 },
]);
});
});
describe('customer service media upload', () => {
beforeEach(() => {
mocks.instances.length = 0;
});
it('keeps credentials in headers and out of the upload URL', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ ctrl: { params: { url: '/v0/file/s/file-1' } } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan1.shop' });
await (chat as any).upload(new File(['image'], 'test.png', { type: 'image/png' }));
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://www.yuexuan1.shop/v0/file/u/');
expect(String(url)).not.toContain('secret=');
expect(init?.headers).toEqual({
'X-MeetingIM-APIKey': 'public-key',
Authorization: 'Token token-1',
});
fetchMock.mockRestore();
});
it('rewrites historical absolute IM file references to the current frontend origin', () => {
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan3.shop' });
const tinode = mocks.instances[0];
tinode.authorizeURL.mockImplementation((value: string) => `${value}?auth=token-1`);
expect((chat as any).mediaURL('https://im-api.yuexuan.shop/v0/file/s/legacy-image')).toBe(
'https://www.yuexuan3.shop/v0/file/s/legacy-image?auth=token-1',
);
expect((chat as any).mediaURL('https://cdn.example.com/public/image.png')).toBe(
'https://cdn.example.com/public/image.png',
);
});
it('renders inline Drafty images sent by the IM web client', () => {
const onMessages = vi.fn();
new CustomerServiceChat(session(), onMessages, { origin: 'https://www.yuexuan1.shop' });
const tinode = mocks.instances[0];
tinode.topic.messages.mockImplementation((callback: (message: unknown) => void) => {
callback({
seq: 4,
from: 'usr_support',
ts: '2026-10-19T10:02:32.090Z',
content: {
ent: [{
tp: 'IM',
data: {
val: 'aGVsbG8=',
mime: 'image/png',
name: 'proof.png',
width: 70,
height: 70,
},
}],
fmt: [{ len: 1 }],
txt: ' ',
},
});
});
tinode.topic.onData?.();
expect(onMessages).toHaveBeenCalledWith([
expect.objectContaining({
seq: 4,
type: 'image',
media_url: 'data:image/png;base64,aGVsbG8=',
file_name: 'proof.png',
}),
]);
});
});
+402
View File
@@ -0,0 +1,402 @@
import { Drafty, Tinode } from 'tinode-sdk';
export interface CustomerServiceSession {
tenant_code: string;
api_base_url: string;
file_url: string;
websocket_url: string;
api_key: string;
token: string;
current_user_id: string;
topic: string;
}
export interface ChatMessage {
seq: number;
from: string;
text: string;
sent_at: string;
mine: boolean;
sending: boolean;
failed: boolean;
type: 'text' | 'image' | 'video';
media_url?: string;
preview_url?: string;
file_name?: string;
}
export type CustomerServiceConnectionState = 'connecting' | 'online' | 'reconnecting' | 'offline';
interface CustomerServiceChatOptions {
origin?: string;
onConnectionState?: (state: CustomerServiceConnectionState) => void;
}
export function resolveCustomerServiceEndpoints(session: CustomerServiceSession, origin: string) {
const pageOrigin = new URL(origin);
const sameOriginURL = (value: string, fallback: string) => {
const configured = new URL(value || fallback, pageOrigin);
const resolved = new URL(`${configured.pathname}${configured.search}`, pageOrigin);
return resolved.toString().replace(/\/$/, '');
};
const websocket = new URL(sameOriginURL(session.websocket_url, '/v0/channels'));
websocket.protocol = pageOrigin.protocol === 'https:' ? 'wss:' : 'ws:';
return {
apiBaseURL: sameOriginURL(session.api_base_url, '/v1/im'),
fileURL: sameOriginURL(session.file_url, '/v0/file'),
websocketURL: websocket.toString().replace(/\/$/, ''),
};
}
export class CustomerServiceChat {
private static readonly reconnectPageSize = 50;
private static readonly maxReconnectPages = 100;
private tinode: any;
private topic: any;
private currentUserID: string;
private onMessages: (messages: ChatMessage[]) => void;
private session: CustomerServiceSession;
private endpoints: ReturnType<typeof resolveCustomerServiceEndpoints>;
private token = '';
private sessionActive = false;
private intentionalDisconnect = false;
private recovery: Promise<void> | null = null;
private onConnectionState: (state: CustomerServiceConnectionState) => void;
constructor(
session: CustomerServiceSession,
onMessages: (messages: ChatMessage[]) => void,
options: CustomerServiceChatOptions = {},
) {
const origin = options.origin || window.location.origin;
this.endpoints = resolveCustomerServiceEndpoints(session, origin);
const endpoint = new URL(this.endpoints.websocketURL);
this.session = session;
this.token = session.token;
this.currentUserID = session.current_user_id;
this.onMessages = onMessages;
this.onConnectionState = options.onConnectionState || (() => undefined);
this.tinode = new Tinode({
appName: '悦选',
host: endpoint.host,
apiKey: session.api_key,
transport: 'ws',
secure: endpoint.protocol === 'wss:',
persist: false,
});
this.installTenantPatch(session.tenant_code);
this.topic = this.tinode.getTopic(session.topic);
this.topic.onData = () => this.refreshMessages();
this.tinode.onDisconnect = () => {
if (!this.intentionalDisconnect && this.sessionActive) {
this.onConnectionState('reconnecting');
}
};
this.tinode.onAutoreconnectIteration = (timeoutMs: number) => {
if (!this.intentionalDisconnect && this.sessionActive) {
this.onConnectionState(timeoutMs >= 0 ? 'reconnecting' : 'offline');
}
};
this.tinode.onConnect = () => {
if (!this.intentionalDisconnect && this.sessionActive) {
return this.restoreAfterReconnect();
}
};
}
async connect(token: string) {
this.intentionalDisconnect = false;
this.token = token;
this.onConnectionState('connecting');
await this.tinode.connect();
const login = await this.tinode.loginToken(this.token);
if (login?.code >= 300) throw new Error('客服会话登录失败');
this.acceptRotatedToken(login);
const query = this.topic.startMetaQuery().withData(undefined, undefined, 50).build();
await this.requestMessagePage(() => this.topic.subscribe(query), '客服会话订阅失败');
this.sessionActive = true;
this.onConnectionState('online');
this.refreshMessages();
}
async send(text: string) {
const value = text.trim();
if (!value) return;
const message = this.topic.createMessage(value, false);
message.head = Object.assign(message.head || {}, { 'x-im-client-mid': clientMID() });
const result = await this.topic.publishDraft(message, Promise.resolve());
if (!result || result.code >= 300 || message._failed || message._fatal) {
throw new Error(result?.text || '消息发送失败,请重试');
}
}
async sendMedia(file: File, type: 'image' | 'video', onProgress?: (progress: number) => void) {
const dimensions = await mediaDimensions(file, type);
const ref = await this.upload(file, onProgress);
const descriptor = {
mime: file.type,
// Keep the server-issued relative reference. Tinode only includes
// relative references in extra.attachments, which lets the server bind
// the uploaded file to this message and authorize the recipient.
refurl: ref,
filename: file.name,
size: file.size,
width: dimensions.width,
height: dimensions.height,
duration: dimensions.duration,
};
const content = type === 'image'
? Drafty.appendImage(null, descriptor)
: Drafty.insertVideo(null, 0, descriptor);
const message = this.topic.createMessage(content, false);
message.head = Object.assign(message.head || {}, {
'x-im-client-mid': clientMID(),
'x-im-type': type,
});
const result = await this.topic.publishDraft(message, Promise.resolve());
if (!result || result.code >= 300 || message._failed || message._fatal) {
throw new Error(result?.text || `${type === 'image' ? '照片' : '视频'}发送失败,请重试`);
}
}
disconnect() {
this.intentionalDisconnect = true;
this.sessionActive = false;
this.onConnectionState('offline');
try {
this.topic?.leave?.(false);
} finally {
this.tinode?.disconnect?.();
}
}
private restoreAfterReconnect() {
if (this.recovery) return this.recovery;
this.recovery = (async () => {
this.onConnectionState('reconnecting');
const login = await this.tinode.loginToken(this.token);
if (login?.code >= 300) throw new Error('客服会话重新登录失败');
this.acceptRotatedToken(login);
await this.restoreSubscriptionAndMessages();
this.refreshMessages();
this.onConnectionState('online');
})()
.catch(() => {
this.onConnectionState('offline');
})
.finally(() => {
this.recovery = null;
});
return this.recovery;
}
private acceptRotatedToken(login: any) {
const rotated = String(login?.params?.token || '');
if (!rotated) return;
this.token = rotated;
this.session.token = rotated;
}
private async restoreSubscriptionAndMessages() {
const pageSize = CustomerServiceChat.reconnectPageSize;
const firstQuery = this.topic.startMetaQuery().withLaterData(pageSize).build();
let count = await this.requestMessagePage(() => this.topic.subscribe(firstQuery), '客服会话重新订阅失败');
for (let page = 1; count >= pageSize; page += 1) {
if (page >= CustomerServiceChat.maxReconnectPages) {
throw new Error('客服消息积压过多,请重新进入客服会话');
}
const previousMax = Number(this.topic.maxMsgSeq?.() || 0);
const query = this.topic.startMetaQuery().withLaterData(pageSize).build();
count = await this.requestMessagePage(() => this.topic.getMeta(query), '客服历史消息补拉失败');
const currentMax = Number(this.topic.maxMsgSeq?.() || 0);
if (count > 0 && currentMax <= previousMax) {
throw new Error('客服历史消息补拉未推进');
}
}
}
private async requestMessagePage(request: () => Promise<any>, errorMessage: string) {
let observedCount: number | undefined;
let resolveObserved: ((count: number) => void) | undefined;
const observed = new Promise<number>((resolve) => {
resolveObserved = resolve;
});
const previousHandler = this.topic.onAllMessagesReceived;
this.topic.onAllMessagesReceived = (count: number) => {
previousHandler?.(count);
observedCount = Number(count || 0);
resolveObserved?.(observedCount);
};
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
const response = await request();
if (response?.code >= 300) throw new Error(errorMessage);
if (observedCount !== undefined) return observedCount;
return await Promise.race([
observed,
new Promise<number>((_, reject) => {
// A Tinode request promise resolves before the SDK finishes routing
// its data packets. Only this completion callback guarantees that
// maxMsgSeq and the local message cache belong to the finished page.
timeout = setTimeout(() => reject(new Error(errorMessage)), 5_000);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
this.topic.onAllMessagesReceived = previousHandler;
}
}
private installTenantPatch(tenantCode: string) {
const connection = this.tinode._connection;
if (!connection || connection.__olonsTenantPatched) return;
const original = connection.sendText.bind(connection);
connection.sendText = (packet: unknown) => {
if (typeof packet === 'string' && packet.includes('"hi":')) {
try {
const parsed = JSON.parse(packet);
if (parsed.hi) {
parsed.hi.tenant = tenantCode;
packet = JSON.stringify(parsed);
}
} catch {
// Preserve the original packet when it is not JSON.
}
}
return original(packet);
};
connection.__olonsTenantPatched = true;
}
private refreshMessages() {
const rows: ChatMessage[] = [];
this.topic.messages((message: any) => {
const parsed = messageContent(message?.content, (ref) => this.mediaURL(ref));
if (!parsed.text && !parsed.media_url) return;
rows.push({
seq: Number(message.seq || 0),
from: String(message.from || ''),
text: parsed.text,
sent_at: message.ts ? new Date(message.ts).toISOString() : '',
mine: message.from === this.currentUserID,
sending: Boolean(message._sending),
failed: Boolean(message._failed || message._fatal),
type: parsed.type,
media_url: parsed.media_url,
preview_url: parsed.preview_url,
file_name: parsed.file_name,
});
});
this.onMessages(rows.sort((a, b) => a.seq - b.seq));
}
private async upload(file: File, onProgress?: (progress: number) => void) {
const uploadURL = new URL(this.endpoints.fileURL);
uploadURL.pathname = `${uploadURL.pathname.replace(/\/$/, '').replace(/\/u$/, '')}/u/`;
const form = new FormData();
form.set('id', clientMID());
form.set('file', file, file.name);
onProgress?.(0.1);
let response: Response;
try {
// Keep uploads on fetch so API and token credentials stay in headers and
// never leak into the URL, browser history, or proxy access logs.
response = await fetch(uploadURL.toString(), {
method: 'POST',
headers: {
'X-MeetingIM-APIKey': this.session.api_key,
Authorization: `Token ${this.token}`,
},
body: form,
});
} catch {
throw new Error('媒体上传失败,请检查网络后重试');
}
let payload: any;
try {
payload = await response.json();
} catch {
throw new Error('媒体上传响应格式不正确');
}
const ref = String(payload?.ctrl?.params?.url || '');
if (!response.ok || !ref) {
throw new Error(payload?.ctrl?.text || '媒体上传失败');
}
onProgress?.(1);
return ref;
}
private mediaURL(ref?: string) {
if (!ref) return '';
const apiBase = new URL(this.endpoints.apiBaseURL);
const resolved = new URL(ref, apiBase);
// Historical messages may contain the old absolute IM host. Route only
// known IM file paths through the current frontend origin; leave unrelated
// external media untouched.
if (!resolved.pathname.startsWith('/v0/file/') && resolved.origin !== apiBase.origin) return ref;
const signable = `${resolved.pathname}${resolved.search}`;
const authorized = this.tinode.authorizeURL(signable);
return new URL(authorized, this.endpoints.apiBaseURL).toString();
}
}
function messageContent(content: unknown, mediaURL: (ref?: string) => string) {
if (typeof content === 'string') return { text: content, type: 'text' as const };
if (content && typeof content === 'object') {
const drafty = content as { txt?: unknown; ent?: Array<{ tp?: string; data?: Record<string, unknown> }> };
const media = drafty.ent?.find((entity) => entity?.tp === 'IM' || entity?.tp === 'VD');
if (media?.data) {
const type = media.tp === 'VD' ? 'video' : 'image';
const inline = typeof media.data.val === 'string' ? media.data.val : '';
const mime = typeof media.data.mime === 'string' && media.data.mime.startsWith(`${type}/`)
? media.data.mime
: type === 'video' ? 'video/webm' : 'image/jpeg';
return {
text: '',
type,
media_url: inline ? `data:${mime};base64,${inline}` : mediaURL(String(media.data.ref || '')),
preview_url: mediaURL(String(media.data.preref || '')),
file_name: String(media.data.name || ''),
} as const;
}
return { text: String(drafty.txt || ''), type: 'text' as const };
}
return { text: '', type: 'text' as const };
}
function mediaDimensions(file: File, type: 'image' | 'video') {
return new Promise<{ width: number; height: number; duration: number }>((resolve, reject) => {
const url = URL.createObjectURL(file);
const cleanup = () => URL.revokeObjectURL(url);
if (type === 'image') {
const image = new Image();
image.onload = () => {
cleanup();
resolve({ width: image.naturalWidth, height: image.naturalHeight, duration: 0 });
};
image.onerror = () => {
cleanup();
reject(new Error('无法读取所选照片'));
};
image.src = url;
return;
}
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
cleanup();
resolve({ width: video.videoWidth, height: video.videoHeight, duration: Math.round(video.duration * 1000) });
};
video.onerror = () => {
cleanup();
reject(new Error('无法读取所选视频'));
};
video.src = url;
});
}
function clientMID() {
return crypto.randomUUID?.() || `mid_${Date.now()}_${Math.random().toString(36).slice(2)}`;
}
+826 -166
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
import { shallowRef } from 'vue';
import type { AnnouncementNotice } from './components/AnnouncementPopup.vue';
export const activeSystemNotice = shallowRef<AnnouncementNotice | null>(null);
export function openSystemNotice(notice: AnnouncementNotice) {
if (activeSystemNotice.value) return;
activeSystemNotice.value = notice;
}
export function closeSystemNotice() {
activeSystemNotice.value = null;
}
+16 -3
View File
@@ -7,9 +7,12 @@
<img class="m-agency-hero" :src="mobileAssets.agencyHero" alt="邀请好友成为平台代理" /> <img class="m-agency-hero" :src="mobileAssets.agencyHero" alt="邀请好友成为平台代理" />
<section class="m-agency-block"> <section class="m-agency-block">
<h2><i>1</i>什么是平台代理?</h2> <h2><i>1</i>{{ content?.title || '什么是平台代理?' }}</h2>
<p>平台用户可通过推荐新人成为平台代理,代理能够获得额外动态奖励。</p> <div v-if="content" class="m-managed-rich-text" v-html="content.body_html"></div>
<p>被推荐用户获得每日佣金后,代理按对应层级比例获得奖励。</p> <template v-else>
<p>平台用户可通过推荐新人成为平台代理,代理能够获得额外动态奖励。</p>
<p>被推荐用户获得每日佣金后,代理按对应层级比例获得奖励。</p>
</template>
</section> </section>
<section class="m-agency-block"> <section class="m-agency-block">
@@ -48,12 +51,22 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import { request } from '../api';
import { mobileAssets } from '../assets'; import { mobileAssets } from '../assets';
const content = ref<{ title: string; body_html: string }>();
const rewards = [ const rewards = [
{ level: '一级用户', rate: '16%', className: 'is-primary', icon: mobileAssets.agencyPersonPrimary }, { level: '一级用户', rate: '16%', className: 'is-primary', icon: mobileAssets.agencyPersonPrimary },
{ level: '二级用户', rate: '8%', className: 'is-secondary', icon: mobileAssets.agencyPersonSecondary }, { level: '二级用户', rate: '8%', className: 'is-secondary', icon: mobileAssets.agencyPersonSecondary },
{ level: '三级用户', rate: '4%', className: 'is-tertiary', icon: mobileAssets.agencyPersonTertiary }, { level: '三级用户', rate: '4%', className: 'is-tertiary', icon: mobileAssets.agencyPersonTertiary },
]; ];
onMounted(async () => {
content.value = await request<{ title: string; body_html: string }>({
url: '/v1/contents/agent_cooperation',
}).catch(() => undefined);
});
</script> </script>
+31 -9
View File
@@ -21,7 +21,7 @@
v-model.trim="form.username" v-model.trim="form.username"
autocomplete="username" autocomplete="username"
maxlength="10" maxlength="10"
placeholder="请输入账户名" placeholder="请输入名字"
/> />
</label> </label>
<label class="m-input-row"> <label class="m-input-row">
@@ -47,8 +47,10 @@
<n-icon :component="MailOpenOutline" /> <n-icon :component="MailOpenOutline" />
<input <input
v-model.trim="form.referral_code" v-model.trim="form.referral_code"
maxlength="32" maxlength="6"
placeholder="请输入推荐码(选填)" inputmode="numeric"
autocomplete="off"
placeholder="请输入邀请码(必填)"
/> />
</label> </label>
<router-link v-if="mode === 'login'" class="m-forgot-link" to="/app/password/verify" <router-link v-if="mode === 'login'" class="m-forgot-link" to="/app/password/verify"
@@ -94,27 +96,47 @@
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const mode = ref<'login' | 'register'>('login'); const mode = ref<'login' | 'register'>('login');
const accepted = ref(false); const accepted = ref(true);
const submitting = ref(false); const submitting = ref(false);
const error = ref(''); const error = ref('');
const form = reactive({ username: '', phone: '', password: '', referral_code: '' }); const form = reactive({ username: '', phone: '', password: '', referral_code: '' });
watch(mode, () => { function referralCodeFromLink() {
const value =
route.query.inviteCode ?? route.query.invite_code ?? route.query.referral_code;
return typeof value === 'string' ? value.trim() : '';
}
watch(mode, (value) => {
form.username = ''; form.username = '';
form.phone = ''; form.phone = '';
form.password = ''; form.password = '';
form.referral_code = ''; form.referral_code = value === 'register' ? referralCodeFromLink() : '';
accepted.value = false; accepted.value = true;
error.value = ''; error.value = '';
}); });
watch(
() => [route.query.inviteCode, route.query.invite_code, route.query.referral_code],
() => {
const referralCode = referralCodeFromLink();
if (!referralCode) return;
mode.value = 'register';
form.referral_code = referralCode;
},
{ immediate: true }
);
async function submit() { async function submit() {
error.value = ''; error.value = '';
if (!/^1\d{10}$/.test(form.phone)) return void (error.value = '请输入正确的手机号码'); if (!/^1\d{10}$/.test(form.phone)) return void (error.value = '请输入正确的手机号码');
if (form.password.length < 6) return void (error.value = '密码至少为 6 位'); if (form.password.length < 6) return void (error.value = '密码至少为 6 位');
if (!accepted.value) return void (error.value = '请先阅读并同意用户协议和隐私政策'); if (!accepted.value) return void (error.value = '请先阅读并同意用户协议和隐私政策');
if (mode.value === 'register' && !/^[a-z0-9]{1,10}$/.test(form.username)) { if (mode.value === 'register' && !/^[\p{L}\p{M}·]{1,10}$/u.test(form.username)) {
return void (error.value = '账户名须为 1 至 10 位小写字母或数字'); return void (error.value = '名字须为 1 至 10 个字符,支持汉字');
}
if (mode.value === 'register' && !/^\d{6}$/.test(form.referral_code)) {
return void (error.value = '请输入 6 位数字邀请码');
} }
submitting.value = true; submitting.value = true;
try { try {
+140 -50
View File
@@ -5,7 +5,7 @@
<img :src="mobileAssets.claimMachine" alt="每日参与任务,赚取更多佣金" /> <img :src="mobileAssets.claimMachine" alt="每日参与任务,赚取更多佣金" />
<button <button
type="button" type="button"
:disabled="claiming || !halls.length || eligibility?.eligible === false" :disabled="claiming || !halls.length"
aria-label="自动领取任务" aria-label="自动领取任务"
@click="claim" @click="claim"
> >
@@ -22,21 +22,22 @@
</div> </div>
</section> </section>
<div v-if="eligibility?.eligible === false" class="m-claim2-warning"> <div v-if="balanceInsufficient" class="m-claim2-warning">
<n-icon :component="AlertCircleOutline" /> <n-icon :component="AlertCircleOutline" />
<span>{{ eligibility.message || '暂不满足领取条件' }}</span> <span>可用余额不足299元</span>
<button v-if="eligibility.pending_order_id" type="button" @click="openPendingOrder">查看任务</button>
</div> </div>
<section class="m-claim2-rules"> <section class="m-claim2-rules">
<h2><span></span>活动规则<span></span></h2> <h2><span></span>活动规则<span></span></h2>
<ol> <ol>
<li>平台运营时间每天 {{ businessHours }}。</li> <li>平台运营时间每天上午9:00-晚上21:00点!</li>
<li>每一次储入请先咨询客服获取商家卡号,如有疑问请联系在线客服。</li> <li>每一次储入必须咨询客户获取商家卡号!如有不理解可联系在线客服!</li>
<li>领取后系统将自动分配任务,请按订单要求完成任务内容。</li> <li>领取后系统将自动分配任务,请按订单要求完成任务内容!</li>
<li>领取时间根据会员等级不同,系统随机匹配订单。</li> <li>领取订单的时间是根据会员等级的不同!系统随机匹配订单!</li>
<li>账户可用余额需达到 {{ money(eligibility?.minimum_amount) }} 才可领取。</li> <li>会员账户必须要拥有299启动资金才可进行领取!</li>
<li>订单需要在规定时间内完成,请勿超时提交。</li> <li>订单需要在规定时间内完成!请勿超时完成商品!</li>
<li>平台硬性条件提现必须信用分值满足100分,请广大会员珍惜账户信用分值!信用分下降必须联系客服处理恢复100分才可正常提现,信用分提升金额具体根据会员等级规定,详细咨询平台客服。</li>
<li>非本人充值资金需要提供支付人身份证信息验证充值资金,避免出现非必要的资金纠纷。</li>
</ol> </ol>
</section> </section>
</div> </div>
@@ -45,20 +46,18 @@
<n-modal v-model:show="showResult" :mask-closable="false"> <n-modal v-model:show="showResult" :mask-closable="false">
<section class="m-claim-result" :class="`is-${resultState}`"> <section class="m-claim-result" :class="`is-${resultState}`">
<template v-if="resultState === 'matching'"> <template v-if="resultState === 'matching'">
<h2>正在匹配任务</h2> <h2>正在匹配订单</h2>
<p>{{ matchingHint }}</p> <p>{{ matchingHint }}</p>
<div class="m-match-visual"> <div class="m-match-visual">
<img class="orbit" :src="mobileAssets.claimSpinner" alt="" /> <img class="orbit" :src="mobileAssets.claimSpinner" alt="" />
<img class="gift" :src="mobileAssets.claimMatchingGift" alt="" /> <img class="gift" :src="mobileAssets.claimMatchingGift" alt="" />
</div> </div>
<div class="m-match-progress"><i :style="{ width: `${matchProgress}%` }"></i></div> <div v-if="isAutoMatching" class="m-match-progress"><i :style="{ width: `${matchProgress}%` }"></i></div>
<strong class="m-match-percent">{{ matchProgress }}%</strong> <strong v-if="isAutoMatching" class="m-match-percent">{{ matchProgress }}%</strong>
<ol class="m-match-steps"> <ol class="m-match-steps m-match-steps--single">
<li v-for="(step, index) in matchSteps" :key="step" :class="{ active: index <= matchStep }"> <li class="active"><span>✓</span>正在匹配订单</li>
<span>✓</span>{{ step }}
</li>
</ol> </ol>
<button class="m-result-secondary" @click="openOrders">后台匹配,去记录查看</button> <button class="m-result-secondary" @click="openOrders">稍后去记录查看</button>
</template> </template>
<template v-else> <template v-else>
@@ -100,7 +99,7 @@
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { AlertCircleOutline, ImageOutline } from '@vicons/ionicons5'; import { AlertCircleOutline, ImageOutline } from '@vicons/ionicons5';
import AsyncState from '../components/AsyncState.vue'; import AsyncState from '../components/AsyncState.vue';
import { dateTime, errorText, money, request, safeImage } from '../api'; import { MobileApiError, dateTime, errorText, money, request, safeImage } from '../api';
import { mobileAssets } from '../assets'; import { mobileAssets } from '../assets';
type ResultState = 'matching' | 'success' | 'failed' | 'cancelled'; type ResultState = 'matching' | 'success' | 'failed' | 'cancelled';
@@ -112,14 +111,17 @@
const halls = ref<any[]>([]); const halls = ref<any[]>([]);
const stats = ref<any>(); const stats = ref<any>();
const eligibility = ref<any>(); const eligibility = ref<any>();
const eligibilityByHall = ref<Record<number, any>>({});
const order = ref<any>(); const order = ref<any>();
const showResult = ref(false); const showResult = ref(false);
const resultState = ref<ResultState>('matching'); const resultState = ref<ResultState>('matching');
const failureMessage = ref('当前任务库存未分配成功,请稍后重试'); const failureMessage = ref('当前任务库存未分配成功,请稍后重试');
const matchProgress = ref(18); const matchProgress = ref(18);
const matchRemainingSeconds = ref(0);
const matchTotalSeconds = ref(0);
const pollFinished = ref(false); const pollFinished = ref(false);
let pollTimer: number | undefined; let pollTimer: number | undefined;
let progressTimer: number | undefined; let countdownTimer: number | undefined;
let pollCount = 0; let pollCount = 0;
const statCards = computed(() => [ const statCards = computed(() => [
@@ -128,14 +130,21 @@
{ label: '今日佣金', value: money(stats.value?.today_commission_amount), icon: mobileAssets.claimToday }, { label: '今日佣金', value: money(stats.value?.today_commission_amount), icon: mobileAssets.claimToday },
{ label: '今日领取数', value: stats.value?.today_claim_count ?? 0, icon: mobileAssets.claimCount }, { label: '今日领取数', value: stats.value?.today_claim_count ?? 0, icon: mobileAssets.claimCount },
]); ]);
const businessHours = computed(() => { const eligibleHalls = computed(() =>
const start = String(eligibility.value?.business_start_at || '09:00').slice(0, 5); halls.value.filter((hall) => eligibilityByHall.value[hall.id]?.eligible)
const end = String(eligibility.value?.business_end_at || '21:00').slice(0, 5); );
return `${start}-${end}`; const balanceInsufficient = computed(() =>
Object.values(eligibilityByHall.value).some((item: any) =>
Number(item?.minimum_amount || 0) > 0 &&
Number(item?.available_amount || 0) < Number(item.minimum_amount)
)
);
const isAutoMatching = computed(() => order.value?.dispatch_mode !== 'manual');
const matchingHint = computed(() => {
if (order.value?.dispatch_mode === 'manual') return '订单已领取,正在等待后台匹配...';
if (matchRemainingSeconds.value > 0) return `系统正在派单... ${formatCountdown(matchRemainingSeconds.value)}`;
return pollFinished.value ? '订单仍在匹配,可稍后前往记录查看' : '系统正在为您匹配订单,请稍等...';
}); });
const matchStep = computed(() => (matchProgress.value < 35 ? 0 : matchProgress.value < 60 ? 1 : matchProgress.value < 85 ? 2 : 3));
const matchSteps = ['正在匹配任务', '获取商品信息', '确认任务内容', '发放任务结果'];
const matchingHint = computed(() => pollFinished.value ? '任务仍在匹配,可稍后前往记录查看' : '系统正在为您匹配任务,请稍等...');
const resultIcon = computed(() => resultState.value === 'success' ? mobileAssets.claimSuccess : resultState.value === 'failed' ? mobileAssets.claimFailed : mobileAssets.claimCancelled); const resultIcon = computed(() => resultState.value === 'success' ? mobileAssets.claimSuccess : resultState.value === 'failed' ? mobileAssets.claimFailed : mobileAssets.claimCancelled);
const resultTitle = computed(() => resultState.value === 'success' ? '任务获取成功' : resultState.value === 'failed' ? '领取失败' : '已取消任务'); const resultTitle = computed(() => resultState.value === 'success' ? '任务获取成功' : resultState.value === 'failed' ? '领取失败' : '已取消任务');
const resultDescription = computed(() => resultState.value === 'success' ? '恭喜你成功领取任务' : resultState.value === 'failed' ? '本次任务领取未成功,请稍后再试' : '本次任务已取消,可重新领取'); const resultDescription = computed(() => resultState.value === 'success' ? '恭喜你成功领取任务' : resultState.value === 'failed' ? '本次任务领取未成功,请稍后再试' : '本次任务已取消,可重新领取');
@@ -154,7 +163,10 @@
]); ]);
halls.value = hallData || []; halls.value = hallData || [];
stats.value = statData; stats.value = statData;
if (halls.value.length) await loadEligibility(halls.value[0].id); if (halls.value.length) {
await loadEligibilities();
await resumeMatchingOrder();
}
} catch (err) { } catch (err) {
error.value = errorText(err); error.value = errorText(err);
} finally { } finally {
@@ -162,27 +174,64 @@
} }
} }
async function loadEligibility(hallID: number) { async function loadEligibilities() {
eligibility.value = await request({ url: '/v1/tasks/eligibility', params: { hall_id: hallID } }); const entries = await Promise.all(
halls.value.map(async (hall) => [
hall.id,
await request<any>({ url: '/v1/tasks/eligibility', params: { hall_id: hall.id } }),
] as const)
);
eligibilityByHall.value = Object.fromEntries(entries);
eligibility.value = entries.find(([, value]) => value?.eligible)?.[1] || entries[0]?.[1];
}
async function resumeMatchingOrder() {
const pendingOrderID = Object.values(eligibilityByHall.value)
.map((item: any) => Number(item?.pending_order_id || 0))
.find((id) => id > 0);
if (!pendingOrderID) return;
try {
const pendingOrder = await request<any>({ url: `/v1/task-orders/${pendingOrderID}` });
if (pendingOrder?.status !== 'matching') return;
openMatching();
handleOrder(pendingOrder);
} catch {
// 保留页面上的“存在未完成订单”入口,避免详情请求失败阻断领取页加载。
}
} }
async function claim() { async function claim() {
if (claiming.value || !halls.value.length) return; if (claiming.value || !halls.value.length) return;
claiming.value = true; claiming.value = true;
pollFinished.value = false; pollFinished.value = false;
order.value = undefined;
try { try {
await loadEligibility(halls.value[0].id); await loadEligibilities();
if (!eligibility.value?.eligible) { const candidates = eligibleHalls.value;
window.$message.warning(eligibility.value?.message || '暂不满足领取条件'); if (!candidates.length) {
return; throw new Error(eligibility.value?.message || '暂不满足领取条件');
} }
openMatching(); openMatching();
order.value = await request<any>({ let stockError: unknown;
url: '/v1/task-orders/claim', for (const hall of candidates) {
method: 'POST', eligibility.value = eligibilityByHall.value[hall.id];
data: { hall_id: halls.value[0].id }, try {
}); order.value = await request<any>({
handleOrder(order.value); url: '/v1/task-orders/claim',
method: 'POST',
data: { hall_id: hall.id },
});
handleOrder(order.value);
return;
} catch (err) {
if (err instanceof MobileApiError && err.reason === 'NO_TASK_AVAILABLE') {
stockError = err;
continue;
}
throw err;
}
}
throw stockError || new Error('当前暂无可领取任务');
} catch (err) { } catch (err) {
failureMessage.value = errorText(err); failureMessage.value = errorText(err);
resultState.value = 'failed'; resultState.value = 'failed';
@@ -198,15 +247,23 @@
resultState.value = 'matching'; resultState.value = 'matching';
showResult.value = true; showResult.value = true;
matchProgress.value = 18; matchProgress.value = 18;
matchRemainingSeconds.value = 0;
matchTotalSeconds.value = 0;
pollCount = 0; pollCount = 0;
progressTimer = window.setInterval(() => {
matchProgress.value = Math.min(88, matchProgress.value + Math.ceil(Math.random() * 5));
}, 700);
} }
function handleOrder(value: any) { function handleOrder(value: any) {
order.value = value; order.value = value;
if (value?.status === 'matching') { if (value?.status === 'matching') {
if (value?.dispatch_mode === 'manual') {
if (countdownTimer) window.clearInterval(countdownTimer);
countdownTimer = undefined;
matchRemainingSeconds.value = 0;
matchTotalSeconds.value = 0;
matchProgress.value = 18;
} else {
startCountdown(value);
}
schedulePoll(); schedulePoll();
} else if (['pending', 'processing', 'completed'].includes(value?.status)) { } else if (['pending', 'processing', 'completed'].includes(value?.status)) {
matchProgress.value = 100; matchProgress.value = 100;
@@ -224,6 +281,13 @@
function schedulePoll() { function schedulePoll() {
if (!order.value?.id) return; if (!order.value?.id) return;
if (pollTimer) window.clearTimeout(pollTimer);
const scheduledAt = Date.parse(String(order.value?.scheduled_match_at || ''));
const wait = order.value?.dispatch_mode === 'manual'
? 10000
: Number.isFinite(scheduledAt) && scheduledAt > Date.now()
? scheduledAt - Date.now() + 300
: 1200;
pollTimer = window.setTimeout(async () => { pollTimer = window.setTimeout(async () => {
pollCount += 1; pollCount += 1;
try { try {
@@ -238,14 +302,44 @@
pollFinished.value = true; pollFinished.value = true;
stopPolling(); stopPolling();
} }
}, 1200); }, wait);
}
function startCountdown(value: any) {
if (countdownTimer) window.clearInterval(countdownTimer);
if (value?.dispatch_mode === 'manual') return;
const scheduledAt = Date.parse(String(value?.scheduled_match_at || ''));
if (!Number.isFinite(scheduledAt)) return;
matchTotalSeconds.value = Math.max(1, Number(value?.dispatch_delay_seconds || 0));
const update = () => {
matchRemainingSeconds.value = Math.max(0, Math.ceil((scheduledAt - Date.now()) / 1000));
const elapsed = Math.max(0, matchTotalSeconds.value - matchRemainingSeconds.value);
matchProgress.value = Math.min(88, Math.round(18 + elapsed / matchTotalSeconds.value * 70));
if (matchRemainingSeconds.value === 0 && countdownTimer) {
window.clearInterval(countdownTimer);
countdownTimer = undefined;
}
};
update();
if (matchRemainingSeconds.value > 0) countdownTimer = window.setInterval(update, 1000);
}
function formatCountdown(totalSeconds: number) {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor(totalSeconds % 3600 / 60);
const seconds = totalSeconds % 60;
return hours > 0
? [hours, minutes, seconds].map((item) => String(item).padStart(2, '0')).join(':')
: [minutes, seconds].map((item) => String(item).padStart(2, '0')).join(':');
} }
function stopPolling(clearProgress = true) { function stopPolling(clearProgress = true) {
if (pollTimer) window.clearTimeout(pollTimer); if (pollTimer) window.clearTimeout(pollTimer);
if (clearProgress && progressTimer) window.clearInterval(progressTimer); if (clearProgress && countdownTimer) window.clearInterval(countdownTimer);
pollTimer = undefined; pollTimer = undefined;
if (clearProgress) progressTimer = undefined; if (clearProgress) {
countdownTimer = undefined;
}
} }
function closeResult() { function closeResult() {
@@ -256,10 +350,6 @@
closeResult(); closeResult();
router.push('/app/orders'); router.push('/app/orders');
} }
function openPendingOrder() {
const orderID = Number(eligibility.value?.pending_order_id || 0);
router.push(orderID > 0 ? `/app/orders/${orderID}` : '/app/orders');
}
function retryClaim() { function retryClaim() {
closeResult(); closeResult();
void claim(); void claim();
+219 -72
View File
@@ -2,11 +2,6 @@
<section <section
class="m-page m-page--white m-subpage" class="m-page m-page--white m-subpage"
:class="{ 'm-subpage--profile': formType === 'profile' }" :class="{ 'm-subpage--profile': formType === 'profile' }"
:style="
formType === 'profile'
? { backgroundImage: `url(${mobileAssets.profileBackground})` }
: undefined
"
> >
<PageHeader :title="title" /> <PageHeader :title="title" />
<AsyncState <AsyncState
@@ -15,71 +10,89 @@
:error="error" :error="error"
@retry="loadProfile" @retry="loadProfile"
> >
<div class="m-profile-edit"> <div class="m-profile-info">
<div class="m-avatar m-avatar--large" <div class="m-profile-info-card m-card">
><img <label class="m-profile-info-row m-profile-avatar-row">
v-if="safeImage(me?.avatar_url)" <n-icon :component="ImageOutline" />
:src="safeImage(me.avatar_url)" <span>头像</span>
alt="用户头像" /><n-icon v-else :component="PersonCircleOutline" /><img <span class="m-profile-avatar-preview">
class="m-avatar-edit" <img v-if="safeImage(me?.avatar_url)" :src="safeImage(me.avatar_url)" alt="用户头像" />
:src="mobileAssets.avatarEdit" <n-icon v-else :component="PersonCircleOutline" />
alt="" <small v-if="avatarUploading">上传中...</small>
/></div> </span>
<div class="m-info-card m-card"> <n-icon :component="ChevronForwardOutline" />
<div <input type="file" accept="image/jpeg,image/png,image/webp" :disabled="avatarUploading" @change="uploadAvatar" />
><span>昵称</span><b>{{ me?.username }}</b></div </label>
><div </div>
><span>手机号码</span><b>{{ me?.phone }}</b></div <p v-if="avatarError" class="m-form-error">{{ avatarError }}</p>
><div <div class="m-profile-info-card m-card">
><span>邀请码</span><b>{{ me?.invite_code }}</b></div <div class="m-profile-info-row">
> <n-icon :component="PhonePortraitOutline" />
<router-link to="/app/profile/password" <span>我的手机号</span>
><span>密码管理</span><n-icon :component="ChevronForwardOutline" <b>{{ me?.phone }}</b>
/></router-link> </div>
<router-link to="/app/profile/trade-password" <router-link class="m-profile-info-row" to="/app/profile/bank-card?from=profile">
><span>交易密码管理</span><n-icon :component="ChevronForwardOutline" <n-icon :component="CardOutline" />
/></router-link> <span>取出信息</span>
<n-icon :component="ChevronForwardOutline" />
</router-link>
<router-link class="m-profile-info-row" to="/app/profile/password">
<n-icon :component="LockClosedOutline" />
<span>密码管理</span>
<n-icon :component="ChevronForwardOutline" />
</router-link>
<router-link class="m-profile-info-row" to="/app/profile/trade-password">
<n-icon :component="LockClosedOutline" />
<span>支付密码管理</span>
<n-icon :component="ChevronForwardOutline" />
</router-link>
</div> </div>
</div> </div>
</AsyncState> </AsyncState>
<form v-else-if="formType === 'bank'" class="m-plain-form" @submit.prevent="saveBank"> <AsyncState v-else-if="formType === 'bank'" :loading="bankLoading" :error="bankLoadError" @retry="loadPaymentAccounts">
<form class="m-plain-form m-bank-form" @submit.prevent="saveBank">
<p v-if="existingAccount?.masked" class="m-bound-account-hint">历史资料无法完整读取,请重新填写姓名和银行卡号后保存</p>
<label <label
><span>姓名</span><input v-model.trim="bank.holder_name" placeholder="请输入姓名" ><span>姓名</span><input v-model.trim="bank.holder_name" placeholder="请输入姓名"
/></label> /></label>
<label <label
><span>银行卡号</span ><span>银行卡号</span
><input v-model.trim="bank.account_no" inputmode="numeric" placeholder="请输入银行卡号" ><input v-model.trim="bank.account_no" inputmode="numeric" placeholder="请输入你的银行卡号"
/></label> /></label>
<label <label
><span>开户行</span><input v-model.trim="bank.bank_name" placeholder="请输入开户行" ><span>开户行</span><input v-model.trim="bank.bank_name" placeholder="请输入开户行"
/></label> /></label>
<label <label
><span>银行编码</span><input v-model.trim="bank.bank_code" placeholder="例如 ICBC" ><span>数字人民币</span
/></label>
<label
><span>预留手机号</span
><input ><input
v-model.trim="bank.reserved_phone" v-model.trim="bank.digital_rmb_code"
inputmode="numeric" inputmode="numeric"
maxlength="11" maxlength="32"
placeholder="请输入预留手机号(选填)" placeholder="请输入数字编码(选填)"
/></label> /></label>
<label <section class="m-qr-field">
><span>交易密码</span <h3>微信二维码</h3>
><input <label class="m-qr-upload">
v-model="bank.trade_password" <input type="file" accept="image/jpeg,image/png,image/webp" @change="uploadQRCode('wechat', $event)" />
type="password" <img v-if="qr.wechat.url" :src="safeImage(qr.wechat.url)" alt="微信二维码" />
inputmode="numeric" <span v-else><n-icon :component="CameraOutline" /><b>{{ qr.wechat.uploading ? '上传中...' : bank.wechat_qr_attachment_id ? '已上传,点击更换' : '点击上传' }}</b></span>
maxlength="6" </label>
placeholder="请输入 6 位交易密码" </section>
/></label> <section class="m-qr-field">
<p class="m-form-hint">微信、支付宝二维码上传能力将在上传接口开放后提供。</p> <h3>支付宝二维码</h3>
<label class="m-qr-upload">
<input type="file" accept="image/jpeg,image/png,image/webp" @change="uploadQRCode('alipay', $event)" />
<img v-if="qr.alipay.url" :src="safeImage(qr.alipay.url)" alt="支付宝二维码" />
<span v-else><n-icon :component="CameraOutline" /><b>{{ qr.alipay.uploading ? '上传中...' : bank.alipay_qr_attachment_id ? '已上传,点击更换' : '点击上传' }}</b></span>
</label>
</section>
<p v-if="error" class="m-form-error">{{ error }}</p <p v-if="error" class="m-form-error">{{ error }}</p
><button class="m-primary-btn" :disabled="submitting">{{ ><button class="m-primary-btn" :disabled="submitting || qr.wechat.uploading || qr.alipay.uploading">{{
submitting ? '提交中...' : '确认绑定' submitting ? '提交中...' : existingAccount ? '保存修改' : '确认绑定'
}}</button> }}</button>
</form> </form>
</AsyncState>
<form <form
v-else-if="formType === 'tradePassword'" v-else-if="formType === 'tradePassword'"
@@ -195,7 +208,8 @@
<article v-if="content" class="m-content" <article v-if="content" class="m-content"
><h1>{{ content.title }}</h1 ><h1>{{ content.title }}</h1
><p class="m-content-summary">{{ content.summary }}</p ><p class="m-content-summary">{{ content.summary }}</p
><div class="m-content-body">{{ content.body_text || '暂无正文' }}</div ><div v-if="content.body_html" class="m-content-body m-managed-rich-text" v-html="content.body_html"></div
><div v-else class="m-content-body">{{ content.body_text || '暂无正文' }}</div
><time v-if="content.published_at" ><time v-if="content.published_at"
>发布时间:{{ dateTime(content.published_at) }}</time >发布时间:{{ dateTime(content.published_at) }}</time
></article ></article
@@ -205,18 +219,21 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { import {
CardOutline,
CameraOutline,
ChevronForwardOutline, ChevronForwardOutline,
ImageOutline,
KeyOutline, KeyOutline,
LockClosedOutline, LockClosedOutline,
PersonCircleOutline, PersonCircleOutline,
PhonePortraitOutline,
} from '@vicons/ionicons5'; } from '@vicons/ionicons5';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import AsyncState from '../components/AsyncState.vue'; import AsyncState from '../components/AsyncState.vue';
import { clearSession, dateTime, errorText, request, safeImage } from '../api'; import { clearSession, dateTime, errorText, request, safeImage } from '../api';
import { mobileAssets } from '../assets';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const formType = computed(() => String(route.meta.form || '')); const formType = computed(() => String(route.meta.form || ''));
@@ -233,20 +250,40 @@
String(route.meta.title || '') String(route.meta.title || '')
); );
const loading = ref(false); const loading = ref(false);
const bankLoading = ref(false);
const bankLoadError = ref('');
const submitting = ref(false); const submitting = ref(false);
const error = ref(''); const error = ref('');
const avatarError = ref('');
const avatarUploading = ref(false);
const me = ref<any>(); const me = ref<any>();
interface PaymentAccountDetail {
id: number;
bank_name: string;
holder_name: string;
account_no: string;
digital_rmb_code: string;
wechat_qr_url: string;
alipay_qr_url: string;
wechat_qr_attachment_id: number;
alipay_qr_attachment_id: number;
masked: boolean;
}
const existingAccount = ref<PaymentAccountDetail | null>(null);
let paymentAccountsLoadVersion = 0;
const content = ref<any>(); const content = ref<any>();
const resetPassword = ref(''); const resetPassword = ref('');
const bank = reactive({ const bank = reactive({
account_type: 'bank_card',
bank_code: '',
bank_name: '', bank_name: '',
holder_name: '', holder_name: '',
account_no: '', account_no: '',
reserved_phone: '', digital_rmb_code: '',
trade_password: '', wechat_qr_attachment_id: 0,
qr_attachment_id: 0, alipay_qr_attachment_id: 0,
});
const qr = reactive({
wechat: { uploading: false, url: '' },
alipay: { uploading: false, url: '' },
}); });
const trade = reactive({ login_password: '', old_trade_password: '', new_trade_password: '' }); const trade = reactive({ login_password: '', old_trade_password: '', new_trade_password: '' });
const loginPassword = reactive({ old_password: '', new_password: '', confirm_password: '' }); const loginPassword = reactive({ old_password: '', new_password: '', confirm_password: '' });
@@ -262,28 +299,130 @@
loading.value = false; loading.value = false;
} }
} }
async function uploadAvatar(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file || avatarUploading.value) return;
avatarError.value = '';
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 5 * 1024 * 1024) {
avatarError.value = '头像仅支持 5MB 以内的 JPG、PNG 或 WebP 图片';
return;
}
avatarUploading.value = true;
try {
const data = new FormData();
data.append('file', file);
const uploaded = await request<{ id: number; url: string }>({
url: '/v1/attachments/images',
method: 'POST',
data,
});
const avatar = await request<{ attachment_id: number; avatar_url: string }>({
url: '/v1/me/avatar',
method: 'PATCH',
data: { attachment_id: uploaded.id },
});
me.value = { ...me.value, avatar_url: avatar.avatar_url || uploaded.url };
window.$message?.success('头像更新成功');
} catch (err) {
avatarError.value = errorText(err);
} finally {
avatarUploading.value = false;
}
}
async function loadPaymentAccounts() {
const version = ++paymentAccountsLoadVersion;
bankLoading.value = true;
bankLoadError.value = '';
error.value = '';
existingAccount.value = null;
Object.assign(bank, { bank_name: '', holder_name: '', account_no: '', digital_rmb_code: '',
wechat_qr_attachment_id: 0, alipay_qr_attachment_id: 0 });
qr.wechat.url = '';
qr.alipay.url = '';
try {
const accounts = await request<PaymentAccountDetail[]>({ url: '/v1/payment-accounts/details' });
if (version !== paymentAccountsLoadVersion || formType.value !== 'bank') return;
const account = accounts[0];
if (!account) return;
existingAccount.value = account;
bank.bank_name = account.bank_name;
bank.wechat_qr_attachment_id = account.wechat_qr_attachment_id;
bank.alipay_qr_attachment_id = account.alipay_qr_attachment_id;
if (!account.masked) {
bank.holder_name = account.holder_name;
bank.account_no = account.account_no;
bank.digital_rmb_code = account.digital_rmb_code;
}
} catch (err) {
if (version === paymentAccountsLoadVersion && formType.value === 'bank') bankLoadError.value = errorText(err);
} finally {
if (version === paymentAccountsLoadVersion) bankLoading.value = false;
}
}
async function saveBank() { async function saveBank() {
error.value = ''; error.value = '';
if ( const accountNo = bank.account_no.replace(/[ -]/g, '');
!bank.holder_name || const digitalRMBCode = bank.digital_rmb_code.replace(/[ -]/g, '');
!/^[0-9 -]{8,36}$/.test(bank.account_no) || const validationError =
!bank.bank_name || bank.holder_name.length < 2
!/^[0-9]{6}$/.test(bank.trade_password) ? '请输入至少 2 个字符的姓名'
) { : !/^\d{8,32}$/.test(accountNo)
error.value = '请完整填写姓名、银行卡、开户行和 6 位交易密码'; ? '请输入 8 至 32 位银行卡号'
: bank.bank_name.length < 2
? '请输入开户行'
: digitalRMBCode && !/^\d{6,32}$/.test(digitalRMBCode)
? '数字人民币编码须为 6 至 32 位数字,也可留空'
: !bank.wechat_qr_attachment_id || !bank.alipay_qr_attachment_id
? '请上传微信和支付宝二维码'
: '';
if (validationError) {
error.value = validationError;
window.$message?.error(validationError);
return; return;
} }
submitting.value = true; submitting.value = true;
try { try {
await request({ url: '/v1/payment-accounts', method: 'POST', data: bank }); await request({ url: '/v1/payment-accounts', method: existingAccount.value ? 'PUT' : 'POST',
window.$message?.success('提交成功,等待后台核验'); data: { ...bank, id: existingAccount.value?.id } });
router.back(); window.$message?.success(existingAccount.value ? '绑定信息已更新' : '银行卡绑定成功');
await router.replace(route.query.from === 'profile' ? '/app/profile/info' : '/app/profile/withdrawal');
} catch (err) { } catch (err) {
error.value = errorText(err); error.value = errorText(err);
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
} }
async function uploadQRCode(kind: 'wechat' | 'alipay', event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 5 * 1024 * 1024) {
error.value = '二维码仅支持 5MB 以内的 JPG、PNG 或 WebP 图片';
return;
}
const target = qr[kind];
target.uploading = true;
error.value = '';
try {
const form = new FormData();
form.append('file', file);
const uploaded = await request<{ id: number; url: string }>({
url: '/v1/attachments/images',
method: 'POST',
data: form,
});
target.url = uploaded.url;
if (kind === 'wechat') bank.wechat_qr_attachment_id = uploaded.id;
else bank.alipay_qr_attachment_id = uploaded.id;
} catch (err) {
error.value = errorText(err);
} finally {
target.uploading = false;
}
}
async function saveTradePassword() { async function saveTradePassword() {
error.value = ''; error.value = '';
if (!/^\d{6}$/.test(trade.new_trade_password) || trade.login_password.length < 6) { if (!/^\d{6}$/.test(trade.new_trade_password) || trade.login_password.length < 6) {
@@ -375,8 +514,16 @@
loading.value = false; loading.value = false;
} }
} }
onMounted(() => { watch(
if (formType.value === 'profile') loadProfile(); () => [formType.value, String(route.params.key || '')],
else if (formType.value === 'content') loadContent(); ([type]) => {
}); paymentAccountsLoadVersion += 1;
error.value = '';
if (type !== 'content') content.value = undefined;
if (type === 'profile') void loadProfile();
else if (type === 'bank') void loadPaymentAccounts();
else if (type === 'content') void loadContent();
},
{ immediate: true }
);
</script> </script>
+215 -15
View File
@@ -1,6 +1,13 @@
<template> <template>
<section class="m-home"> <section class="m-home">
<div class="m-home-hero" :style="bannerStyle"> <div class="m-home-hero" :style="bannerStyle">
<button
v-if="currentBanner && currentBanner.target_type !== 'none'"
class="m-home-banner-link"
type="button"
:aria-label="currentBanner.alt_text || '打开 Banner 链接'"
@click="openBanner(currentBanner)"
></button>
<label class="m-search"> <label class="m-search">
<n-icon :component="SearchOutline" /> <n-icon :component="SearchOutline" />
<input readonly placeholder="搜索活动、商品、问题等" @click="router.push('/app/mall')" /> <input readonly placeholder="搜索活动、商品、问题等" @click="router.push('/app/mall')" />
@@ -16,6 +23,16 @@
> >
<div class="m-coin" aria-hidden="true">¥</div> <div class="m-coin" aria-hidden="true">¥</div>
</div> </div>
<div v-if="(home?.banners?.length || 0) > 1" class="m-home-banner-dots" aria-label="Banner 切换">
<button
v-for="(_, index) in home?.banners"
:key="index"
type="button"
:class="{ active: index === activeBannerIndex }"
:aria-label="`显示第 ${index + 1} 张 Banner`"
@click="selectBanner(index)"
></button>
</div>
</div> </div>
<div class="m-home-body"> <div class="m-home-body">
@@ -31,10 +48,25 @@
</button> </button>
</div> </div>
<div v-if="home?.notices?.length" class="m-notice m-card"> <button
v-if="displayNotice"
class="m-notice m-card"
type="button"
:aria-label="`查看公告:${displayNotice.title || '最新公告'}`"
@click="openNotice"
>
<n-icon :component="MegaphoneOutline" /> <n-icon :component="MegaphoneOutline" />
<p>{{ home.notices[0].title || home.notices[0].content }}</p> <span :class="['m-notice-tag', { 'is-activity': displayNotice.source_type === 'operational_simulation' }]">
</div> {{ displayNotice.source_type === 'operational_simulation' ? '活动' : '公告' }}
</span>
<p ref="noticeCopyRef" :key="displayNotice.id" class="m-notice-copy">
<span :class="['m-notice-track', { 'is-marquee': noticeNeedsMarquee }]">
<span ref="noticeTextRef">{{ noticeBarText }}</span>
<span v-if="noticeNeedsMarquee" aria-hidden="true">{{ noticeBarText }}</span>
</span>
</p>
<n-icon class="m-notice-arrow" :component="ChevronForwardOutline" />
</button>
<section class="m-section m-card m-home-ranking"> <section class="m-section m-card m-home-ranking">
<div class="m-section-title" <div class="m-section-title"
@@ -52,7 +84,7 @@
<i v-else>{{ entry.username.slice(0, 1) }}</i> <i v-else>{{ entry.username.slice(0, 1) }}</i>
</span> </span>
<strong>{{ entry.username }}</strong> <strong>{{ entry.username }}</strong>
<em>{{ formatCount(entry.completed_count) }}</em> <em>{{ formatPoints(entry.claimed_points) }}积分</em>
<small>{{ entry.status || '领取成功' }}</small> <small>{{ entry.status || '领取成功' }}</small>
</router-link> </router-link>
</div> </div>
@@ -77,30 +109,53 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { SearchOutline, MegaphoneOutline } from '@vicons/ionicons5'; import { ChevronForwardOutline, MegaphoneOutline, SearchOutline } from '@vicons/ionicons5';
import AsyncState from '../components/AsyncState.vue'; import AsyncState from '../components/AsyncState.vue';
import { errorText, money, request, safeImage } from '../api'; import type { AnnouncementNotice } from '../components/AnnouncementPopup.vue';
import { openSystemNotice } from '../systemNotices';
import {
errorText,
money,
request,
safeImage,
} from '../api';
import { mobileAssets } from '../assets'; import { mobileAssets } from '../assets';
interface HomeData { interface HomeData {
profile: { username: string; avatar_url: string; level_name: string }; profile: { username: string; avatar_url: string; level_name: string };
wallet: { available_amount: number; frozen_amount: number; currency: string }; wallet: { available_amount: number; frozen_amount: number; currency: string };
features: Record<string, boolean>; features: Record<string, boolean>;
banners: Array<{ image_url?: string; title?: string }>; banners: BannerData[];
notices: Array<{ title?: string; content?: string }>; notices: NoticeData[];
task_summary: { task_summary: {
today_count: number; today_count: number;
today_commission_amount: number; today_commission_amount: number;
yesterday_commission_amount: number; yesterday_commission_amount: number;
}; };
} }
interface BannerData {
id: number;
image_url?: string;
alt_text?: string;
target_type: 'none' | 'internal_route' | 'external_url';
target_value?: string;
}
interface NoticeData {
id: number;
title?: string;
content?: string;
source_type?: string;
}
interface NoticePageData {
items: NoticeData[];
}
interface LeaderboardEntry { interface LeaderboardEntry {
rank: number; rank: number;
username: string; username: string;
avatar_url: string; avatar_url: string;
completed_count: number; claimed_points: number;
status: string; status: string;
} }
const router = useRouter(); const router = useRouter();
@@ -108,9 +163,32 @@
const loading = ref(true); const loading = ref(true);
const error = ref(''); const error = ref('');
const leaderboard = ref<LeaderboardEntry[]>([]); const leaderboard = ref<LeaderboardEntry[]>([]);
const formatCount = (value: number) => new Intl.NumberFormat('zh-CN').format(value || 0); const homeAnnouncement = ref<AnnouncementNotice | null>(null);
const activeBannerIndex = ref(0);
const activeNoticeIndex = ref(0);
let bannerTimer: number | undefined;
let noticeTimer: number | undefined;
let noticeRefreshTimer: number | undefined;
let refreshingNotices = false;
const formatPoints = (value: number) =>
new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
(value || 0) / 100
);
const currentBanner = computed(() => home.value?.banners?.[activeBannerIndex.value]);
const currentNotice = computed(() => home.value?.notices?.[activeNoticeIndex.value]);
const displayNotice = computed(() => currentNotice.value || homeAnnouncement.value);
const noticeBarText = computed(() => {
const title = displayNotice.value?.title?.trim() || '';
const content = displayNotice.value?.content?.trim() || '';
if (title && content && title !== content) return `${title}:${content}`;
return title || content || '查看最新通知';
});
const noticeCopyRef = ref<HTMLElement>();
const noticeTextRef = ref<HTMLElement>();
const noticeNeedsMarquee = ref(false);
let noticeResizeObserver: ResizeObserver | undefined;
const bannerStyle = computed(() => { const bannerStyle = computed(() => {
const url = safeImage(home.value?.banners?.[0]?.image_url); const url = safeImage(currentBanner.value?.image_url);
return { return {
backgroundImage: url backgroundImage: url
? `linear-gradient(180deg, rgba(16,13,8,.18), rgba(16,13,8,.54)), url(${url})` ? `linear-gradient(180deg, rgba(16,13,8,.18), rgba(16,13,8,.54)), url(${url})`
@@ -137,7 +215,7 @@
enabled: home.value?.features?.withdrawal_enabled === true, enabled: home.value?.features?.withdrawal_enabled === true,
}, },
{ {
label: '商城', label: '手工',
path: '/app/mall', path: '/app/mall',
icon: mobileAssets.shortcutMall, icon: mobileAssets.shortcutMall,
enabled: home.value?.features?.manual_mall_enabled !== false, enabled: home.value?.features?.manual_mall_enabled !== false,
@@ -162,21 +240,143 @@
if (item.enabled) router.push(item.path); if (item.enabled) router.push(item.path);
else window.$message?.warning('该功能暂未开放'); else window.$message?.warning('该功能暂未开放');
} }
function selectBanner(index: number) {
activeBannerIndex.value = index;
restartBannerTimer();
}
function openBanner(banner: BannerData) {
const target = String(banner.target_value || '').trim();
if (!target) return;
if (banner.target_type === 'internal_route' && target.startsWith('/')) {
void router.push(target);
} else if (banner.target_type === 'external_url' && /^https:\/\//i.test(target)) {
window.open(target, '_blank', 'noopener,noreferrer');
}
}
function restartBannerTimer() {
if (bannerTimer) window.clearInterval(bannerTimer);
bannerTimer = undefined;
const count = home.value?.banners?.length || 0;
if (count > 1) {
bannerTimer = window.setInterval(() => {
activeBannerIndex.value = (activeBannerIndex.value + 1) % count;
}, 4500);
}
}
function restartNoticeTimer() {
if (noticeTimer) window.clearInterval(noticeTimer);
noticeTimer = undefined;
const count = home.value?.notices?.length || 0;
if (count > 1) {
noticeTimer = window.setInterval(() => {
activeNoticeIndex.value = (activeNoticeIndex.value + 1) % count;
}, 12000);
}
}
function measureNoticeMarquee() {
const element = noticeCopyRef.value;
const text = noticeTextRef.value;
noticeNeedsMarquee.value = Boolean(element && text && text.getBoundingClientRect().width > element.clientWidth + 1);
}
async function load() { async function load() {
loading.value = true; loading.value = true;
error.value = ''; error.value = '';
try { try {
const [homeData, rankData] = await Promise.all([ const noticeRequest = request<AnnouncementNotice | null>({ url: '/v1/notices/popup' }).catch(() => null);
const [homeData, rankData, noticeData] = await Promise.all([
request<HomeData>({ url: '/v1/home' }), request<HomeData>({ url: '/v1/home' }),
request<LeaderboardEntry[]>({ url: '/v1/leaderboard' }).catch(() => []), request<LeaderboardEntry[]>({ url: '/v1/leaderboard' }).catch(() => []),
noticeRequest,
]); ]);
home.value = homeData; home.value = homeData;
leaderboard.value = rankData || []; leaderboard.value = rankData || [];
homeAnnouncement.value = noticeData;
} catch (err) { } catch (err) {
error.value = errorText(err); error.value = errorText(err);
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
onMounted(load);
function openNotice() {
if (currentNotice.value) {
openSystemNotice({
id: currentNotice.value.id,
title: currentNotice.value.title || '会员公告',
content: currentNotice.value.content || '',
source_type: currentNotice.value.source_type,
});
return;
}
if (homeAnnouncement.value) {
openSystemNotice(homeAnnouncement.value);
return;
}
void router.push('/app/content/notices');
}
async function refreshNotices() {
if (document.visibilityState === 'hidden' || refreshingNotices) return;
refreshingNotices = true;
try {
const page = await request<NoticePageData>({
url: '/v1/notices',
params: { page: 1, page_size: 5 },
});
if (home.value) {
const nextNotices = page.items || [];
const previousIds = home.value.notices.map((notice) => notice.id).join(',');
const nextIds = nextNotices.map((notice) => notice.id).join(',');
home.value.notices = nextNotices;
if (previousIds !== nextIds) {
activeNoticeIndex.value = 0;
restartNoticeTimer();
}
}
} catch {
// Keep the latest successfully loaded announcement visible during transient failures.
} finally {
refreshingNotices = false;
}
}
function handleVisibilityChange() {
if (document.visibilityState === 'visible') void refreshNotices();
}
watch(noticeBarText, () => void nextTick(measureNoticeMarquee), { flush: 'post' });
watch(noticeCopyRef, (element, previous) => {
if (previous) noticeResizeObserver?.unobserve(previous);
if (element) noticeResizeObserver?.observe(element);
void nextTick(measureNoticeMarquee);
}, { flush: 'post' });
watch(() => home.value?.banners?.length, () => {
activeBannerIndex.value = 0;
restartBannerTimer();
});
watch(() => home.value?.notices?.length, () => {
activeNoticeIndex.value = 0;
restartNoticeTimer();
});
onMounted(async () => {
await load();
await nextTick();
measureNoticeMarquee();
if (noticeCopyRef.value && typeof ResizeObserver !== 'undefined') {
noticeResizeObserver = new ResizeObserver(measureNoticeMarquee);
noticeResizeObserver.observe(noticeCopyRef.value);
}
noticeRefreshTimer = window.setInterval(() => void refreshNotices(), 15000);
document.addEventListener('visibilitychange', handleVisibilityChange);
});
onBeforeUnmount(() => {
if (bannerTimer) window.clearInterval(bannerTimer);
if (noticeTimer) window.clearInterval(noticeTimer);
if (noticeRefreshTimer) window.clearInterval(noticeRefreshTimer);
noticeResizeObserver?.disconnect();
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
</script> </script>
+8 -5
View File
@@ -22,13 +22,13 @@
</div> </div>
<b>{{ entry.rank }}</b> <b>{{ entry.rank }}</b>
<strong>{{ entry.username }}</strong> <strong>{{ entry.username }}</strong>
<em>{{ number(entry.completed_count) }}</em> <em>{{ points(entry.claimed_points) }} 积分</em>
<small>{{ entry.status }}</small> <small>{{ entry.status }}</small>
</article> </article>
</div> </div>
<section v-if="remaining.length" class="m-rank-table"> <section v-if="remaining.length" class="m-rank-table">
<header><span>排名</span><span>用户昵称</span><span>领取数量</span><span>状态</span></header> <header><span>排名</span><span>用户昵称</span><span>领取积分</span><span>状态</span></header>
<div v-for="entry in remaining" :key="entry.rank" class="m-rank-row"> <div v-for="entry in remaining" :key="entry.rank" class="m-rank-row">
<b>{{ entry.rank }}</b> <b>{{ entry.rank }}</b>
<div class="m-rank-user"> <div class="m-rank-user">
@@ -38,7 +38,7 @@
</span> </span>
<span>{{ entry.username }}</span> <span>{{ entry.username }}</span>
</div> </div>
<strong>{{ number(entry.completed_count) }}</strong> <strong>{{ points(entry.claimed_points) }}</strong>
<em>{{ entry.status.replace('成功', '') }}</em> <em>{{ entry.status.replace('成功', '') }}</em>
</div> </div>
</section> </section>
@@ -57,7 +57,7 @@
rank: number; rank: number;
username: string; username: string;
avatar_url: string; avatar_url: string;
completed_count: number; claimed_points: number;
status: string; status: string;
} }
@@ -66,7 +66,10 @@
const error = ref(''); const error = ref('');
const podium = computed(() => [entries.value[2], entries.value[0], entries.value[1]].filter(Boolean)); const podium = computed(() => [entries.value[2], entries.value[0], entries.value[1]].filter(Boolean));
const remaining = computed(() => entries.value.slice(3)); const remaining = computed(() => entries.value.slice(3));
const number = (value: number) => new Intl.NumberFormat('zh-CN').format(value || 0); const points = (value: number) =>
new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(
(value || 0) / 100
);
const podiumBackground = (rank: number) => const podiumBackground = (rank: number) =>
rank === 1 ? mobileAssets.rankFirst : rank === 2 ? mobileAssets.rankSecond : mobileAssets.rankThird; rank === 1 ? mobileAssets.rankFirst : rank === 2 ? mobileAssets.rankSecond : mobileAssets.rankThird;

Some files were not shown because too many files have changed in this diff Show More