Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f56b84bb10 | ||
|
|
8ce67648b1 | ||
|
|
9bb479b506 | ||
|
|
79c502cb20 | ||
|
|
47b545d050 | ||
|
|
78165b7a15 | ||
|
|
6954d375b5 | ||
|
|
975a2a2ed2 | ||
|
|
5f85472757 | ||
|
|
92b5656f3f | ||
|
|
cdebd0a404 | ||
|
|
ba87d70478 | ||
|
|
db1367b8fe | ||
|
|
db18514e6d | ||
|
|
c39ec78e89 | ||
|
|
e6c421ac3e | ||
|
|
74785f948b | ||
|
|
f2f4720340 | ||
|
|
dffd47294e | ||
|
|
950890a734 | ||
|
|
179a9bdd16 | ||
|
|
aa0e17f1f1 | ||
|
|
47bb4fe2ed | ||
|
|
355d59813b | ||
|
|
04a3a0f538 | ||
|
|
1c11c0fa82 | ||
|
|
5e0abedbcc | ||
|
|
21371061ed | ||
|
|
319768bd82 | ||
|
|
d8facff163 | ||
|
|
2e47dd3935 | ||
|
|
715740fc30 | ||
|
|
804318e5f5 | ||
|
|
8b773a1a1f | ||
|
|
86efdf4525 | ||
|
|
5a0fe67027 | ||
|
|
f9497118bb | ||
|
|
b79b781f15 | ||
|
|
bce5edfaf5 | ||
|
|
a86db10342 | ||
|
|
70cd0e592a | ||
|
|
443622cd3c | ||
|
|
ecf1a89313 | ||
|
|
07c654b6d4 | ||
|
|
66121f48e9 | ||
|
|
6e223e537b | ||
|
|
eb96b7ac1f | ||
|
|
b6a0f819b8 | ||
|
|
05d6ec26ad | ||
|
|
acf50b9b7c | ||
|
|
06eaf748ed | ||
|
|
a6d7c8dda8 | ||
|
|
37913c37d1 | ||
|
|
48b3564ac9 | ||
|
|
f3f59fe7c3 | ||
|
|
dedacfcd9a | ||
|
|
7eedbde4ab | ||
|
|
acbfaf97a6 | ||
|
|
b3bf41af8b | ||
|
|
45ef10b2af | ||
|
|
2a3c411c9b | ||
|
|
76421fbc2d | ||
|
|
5e01e4344b | ||
|
|
4a70b85353 | ||
|
|
b2ebea4c80 |
@@ -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"
|
||||||
|
'"'"'
|
||||||
|
'
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ go.work.sum
|
|||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
.idea/
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
storage/uploads/
|
||||||
|
|
||||||
# ---> Node
|
# ---> Node
|
||||||
# Logs
|
# Logs
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 645 KiB |
|
After Width: | Height: | Size: 718 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
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 数据。
|
||||||
@@ -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 @@
|
|||||||
| **状态** | **用户可见文案** | **允许操作** | **系统约束** |
|
| **状态** | **用户可见文案** | **允许操作** | **系统约束** |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 匹配中 | 正在匹配任务 | 取消等待 | 只允许一个匹配请求 |
|
| 匹配中 | 正在匹配任务 | 取消等待 | 只允许一个匹配请求 |
|
||||||
| 待处理 | 请在截止时间前完成 | 查看详情、提交 | 倒计时与服务端时间一致 |
|
| 待处理 | 请在截止时间前完成 | 查看详情、立即提交 | 倒计时与服务端时间一致 |
|
||||||
| 处理中 | 已提交,等待确认 | 查看详情、联系客服 | 防重复提交 |
|
| 处理中 | 系统正在结算 | 查看详情、联系客服 | 防重复提交;正常情况为瞬时状态 |
|
||||||
| 已完成 | 本金与佣金已入账 | 查看流水 | 三笔或组合流水必须可核对 |
|
| 已完成 | 佣金已入账 | 查看流水 | 订单、提交记录和佣金流水必须可核对 |
|
||||||
| 超时或冻结 | 显示原因与处理路径 | 申诉、联系客服 | 需要人工复核和审计 |
|
| 超时或冻结 | 显示原因与处理路径 | 申诉、联系客服 | 需要人工复核和审计 |
|
||||||
| 失败或取消 | 显示失败原因 | 重新领取 | 不得产生不一致余额 |
|
| 失败或取消 | 显示失败原因 | 重新领取 | 不得产生不一致余额 |
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 = "/"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, "/")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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[:])
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = ©
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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("余额不足待处理订单不应允许提交")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||||
|
|||||||
@@ -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('--');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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('/', '-');
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
|
After Width: | Height: | Size: 645 KiB |
|
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>
|
||||||
@@ -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',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||