Compare commits
27
Commits
32c6238751
...
bce5edfaf5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -33,5 +33,8 @@ jobs:
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
kubectl set env deployment/olons-server \
|
||||
-n olons-server-system \
|
||||
OLONS_IM_INTERNAL_SECRET="${{ secrets.OLONS_IM_INTERNAL_SECRET }}"
|
||||
kubectl rollout restart deployment/olons-server -n olons-server-system
|
||||
kubectl rollout status deployment/olons-server -n olons-server-system --timeout=120s
|
||||
|
||||
@@ -26,6 +26,8 @@ go.work.sum
|
||||
# env file
|
||||
.env
|
||||
.idea/
|
||||
.DS_Store
|
||||
storage/uploads/
|
||||
|
||||
# ---> Node
|
||||
# Logs
|
||||
|
||||
+6
-6
@@ -6,7 +6,7 @@ RUN corepack enable
|
||||
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY web/ ./
|
||||
ENV VITE_PUBLIC_PATH=/admin/
|
||||
ENV VITE_PUBLIC_PATH=/
|
||||
RUN pnpm run build
|
||||
|
||||
# ---- Stage 2: build the Go backend ----
|
||||
@@ -17,16 +17,16 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
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
|
||||
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache tzdata
|
||||
ENV WORKBENCH_CONFIG=/app/configs/workbench.test.json
|
||||
COPY --from=builder /out/im_workbench /app/im_workbench
|
||||
ENV OLONS_SERVER_CONFIG=/app/configs/olons_server.test.json
|
||||
COPY --from=builder /out/olons_server /app/olons_server
|
||||
COPY configs /app/configs
|
||||
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
|
||||
ENTRYPOINT ["/app/im_workbench"]
|
||||
ENTRYPOINT ["/app/olons_server"]
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
.PHONY: build test run web-install web-build
|
||||
|
||||
build:
|
||||
go build -o bin/im_workbench ./cmd/workbench
|
||||
go build -o bin/olons_server ./cmd/olons_server
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
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:
|
||||
cd web && pnpm install --frozen-lockfile
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Workbench
|
||||
# Olons Server
|
||||
|
||||
移动端任务与账户平台后端。项目是独立应用,业务数据以 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/payment-accounts`
|
||||
- `POST /v1/payment-accounts`
|
||||
- `PUT /v1/payment-accounts`
|
||||
- `GET /v1/payment-accounts/details`
|
||||
- `POST /v1/withdrawal-orders/quote`
|
||||
- `POST /v1/withdrawal-orders`
|
||||
- `GET /v1/withdrawal-orders`
|
||||
@@ -42,17 +44,16 @@
|
||||
核心环境变量:
|
||||
|
||||
```bash
|
||||
WORKBENCH_CONFIG=configs/workbench.local.json
|
||||
WORKBENCH_MYSQL_DSN='user:password@tcp(host:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local'
|
||||
WORKBENCH_REDIS_ADDR='127.0.0.1:6379'
|
||||
WORKBENCH_DATA_ENCRYPTION_KEY='<32 字节随机密钥的 Base64>'
|
||||
WORKBENCH_LOOKUP_HMAC_KEY='<至少 32 字节随机密钥的 Base64>'
|
||||
WORKBENCH_API_KEYS='<API Key,多个以逗号分隔>'
|
||||
OLONS_SERVER_CONFIG=configs/olons_server.local.json
|
||||
OLONS_SERVER_MYSQL_DSN='user:password@tcp(host:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local'
|
||||
OLONS_SERVER_REDIS_ADDR='127.0.0.1:6379'
|
||||
OLONS_SERVER_FINANCE_MASTER_KEY='<至少 32 个字符的固定资金安全密钥>'
|
||||
OLONS_SERVER_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
|
||||
make web-install
|
||||
@@ -60,7 +61,7 @@ make web-build
|
||||
make run
|
||||
```
|
||||
|
||||
生产镜像使用 Docker 多阶段构建自动安装前端依赖、构建 `web/`,并将产物部署到 `/admin/`。
|
||||
生产镜像使用 Docker 多阶段构建自动安装前端依赖、构建 `web/`,并将 C 端产物部署到网站根路径 `/`。
|
||||
|
||||
## 请求头
|
||||
|
||||
@@ -70,7 +71,7 @@ X-Request-ID: <request id>
|
||||
X-Device-ID: <optional device id>
|
||||
```
|
||||
|
||||
默认租户通过 `WORKBENCH_DEFAULT_TENANT_ID` 配置,现有 `olons_admin` 数据使用 `tenant_id=0`。服务不接受客户端传入租户身份。
|
||||
默认租户通过 `OLONS_SERVER_DEFAULT_TENANT_ID` 配置,现有 `olons_admin` 数据使用 `tenant_id=0`。服务不接受客户端传入租户身份。
|
||||
|
||||
## 验证
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"regexp"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -59,9 +59,9 @@ func main() {
|
||||
log.Fatal("mobile_cipher is missing before migration")
|
||||
}
|
||||
|
||||
aeads := newAEADs(append(splitKeys(os.Getenv("WORKBENCH_MEMBER_MIGRATION_DATA_KEYS")), cfg.Auth.DataEncryptionKey))
|
||||
lookupKeys := decodeKeys(append(splitKeys(os.Getenv("WORKBENCH_MEMBER_MIGRATION_LOOKUP_KEYS")), cfg.Auth.LookupHMACKey))
|
||||
overrides, err := parseOverrides(os.Getenv("WORKBENCH_MEMBER_MIGRATION_OVERRIDES"))
|
||||
aeads := newAEADs(append(splitKeys(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_DATA_KEYS")), cfg.Auth.DataEncryptionKey))
|
||||
lookupKeys := decodeKeys(append(splitKeys(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_LOOKUP_KEYS")), cfg.Auth.LookupHMACKey))
|
||||
overrides, err := parseOverrides(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_OVERRIDES"))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/app"
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/app"
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -34,7 +34,7 @@ func main() {
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
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) {
|
||||
errCh <- err
|
||||
}
|
||||
@@ -10,13 +10,16 @@
|
||||
"addr": "127.0.0.1:6379",
|
||||
"db": 0
|
||||
},
|
||||
"upload": {
|
||||
"dir": "storage/uploads",
|
||||
"public_path": "/uploads"
|
||||
},
|
||||
"auth": {
|
||||
"api_keys": [],
|
||||
"session_key_pattern": "workbench:session:token:%s",
|
||||
"session_key_pattern": "olons_server:session:token:%s",
|
||||
"session_ttl_seconds": 1209600,
|
||||
"default_tenant_id": 0,
|
||||
"data_encryption_key": "",
|
||||
"lookup_hmac_key": "",
|
||||
"finance_master_key": "olons-finance-master-key-v1-20261013",
|
||||
"allow_dev_header_auth": true,
|
||||
"required_api_key": false
|
||||
}
|
||||
@@ -10,13 +10,16 @@
|
||||
"addr": "",
|
||||
"db": 0
|
||||
},
|
||||
"upload": {
|
||||
"dir": "storage/uploads",
|
||||
"public_path": "/uploads"
|
||||
},
|
||||
"auth": {
|
||||
"api_keys": [],
|
||||
"session_key_pattern": "workbench:session:token:%s",
|
||||
"session_key_pattern": "olons_server:session:token:%s",
|
||||
"session_ttl_seconds": 1209600,
|
||||
"default_tenant_id": 0,
|
||||
"data_encryption_key": "",
|
||||
"lookup_hmac_key": "",
|
||||
"finance_master_key": "olons-finance-master-key-v1-20261013",
|
||||
"required_api_key": true
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 645 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 718 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 766 KiB |
@@ -0,0 +1,90 @@
|
||||
# Olons Server Manual Actions Entry Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 让 Gitea 在 `olons_server` 仓库的 Actions 页面识别 `dev` 分支现有的手动部署工作流。
|
||||
|
||||
**Architecture:** 不复制或修改工作流文件,不触发部署;仅将仓库默认分支从初始化用的 `main` 改为实际开发与部署分支 `dev`。通过 Gitea API 在变更前后验证默认分支、工作流识别结果和 Actions 运行数量。
|
||||
|
||||
**Tech Stack:** Gitea 1.27 API、Gitea Actions、Git
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 验证变更前状态
|
||||
|
||||
**Files:**
|
||||
- Verify: `.gitea/workflows/deploy.yml`
|
||||
|
||||
- [ ] **Step 1: 确认远端 `dev` 包含手动部署工作流**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git show origin/dev:.gitea/workflows/deploy.yml | grep -F 'workflow_dispatch:'
|
||||
```
|
||||
|
||||
Expected: 输出 `workflow_dispatch:`。
|
||||
|
||||
- [ ] **Step 2: 运行远端契约断言并确认其按预期失败**
|
||||
|
||||
使用现有 Gitea 登录态读取仓库与 Actions API,断言:
|
||||
|
||||
```python
|
||||
assert repository["default_branch"] == "dev"
|
||||
assert any(item["path"] == ".gitea/workflows/deploy.yml" and item["state"] == "active" for item in workflows["workflows"])
|
||||
```
|
||||
|
||||
Expected: FAIL;变更前默认分支为 `main`,识别到的工作流数量为 0。
|
||||
|
||||
### Task 2: 修改默认分支
|
||||
|
||||
**Files:**
|
||||
- Modify: Gitea repository setting `im_team/olons_server.default_branch`
|
||||
|
||||
- [ ] **Step 1: 通过 Gitea API 将默认分支改为 `dev`**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
credential_data=$(printf 'protocol=https\nhost=git.118im.com\n\n' | git credential fill)
|
||||
gitea_user=$(printf '%s\n' "$credential_data" | sed -n 's/^username=//p')
|
||||
gitea_password=$(printf '%s\n' "$credential_data" | sed -n 's/^password=//p')
|
||||
curl -u "$gitea_user:$gitea_password" \
|
||||
-X PATCH https://git.118im.com/api/v1/repos/im_team/olons_server \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"default_branch":"dev"}'
|
||||
unset credential_data gitea_user gitea_password
|
||||
```
|
||||
|
||||
认证信息仅从本机现有 Git credential helper 注入进程,不写入文件、不输出到日志。
|
||||
|
||||
Expected: HTTP 200,响应中的 `default_branch` 为 `dev`。
|
||||
|
||||
### Task 3: 验证手动入口并记录结果
|
||||
|
||||
**Files:**
|
||||
- Create: `/Users/aa/.codex/memories/extensions/ad_hoc/notes/2026-10-13T14-34-07-olons-server-manual-actions.md`
|
||||
|
||||
- [ ] **Step 1: 重新运行远端契约断言**
|
||||
|
||||
Expected: PASS;默认分支为 `dev`,`.gitea/workflows/deploy.yml` 状态为 `active`。
|
||||
|
||||
- [ ] **Step 2: 确认没有因默认分支变更产生新的部署运行**
|
||||
|
||||
Run: 调用 `GET /api/v1/repos/im_team/olons_server/actions/runs?limit=5`。
|
||||
|
||||
Expected: 运行数量与变更前相同;本任务不触发 `workflow_dispatch`。
|
||||
|
||||
- [ ] **Step 3: 推送设计与计划提交**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git push origin dev
|
||||
```
|
||||
|
||||
Expected: 本地 `dev` 与 `origin/dev` 一致。
|
||||
|
||||
- [ ] **Step 4: 更新记忆扩展说明**
|
||||
|
||||
记录默认分支变更、工作流可见性验证、未触发部署以及后续手动运行入口;不得记录凭据值。
|
||||
@@ -32,6 +32,13 @@
|
||||
4. 将 Gitea Actions 工作流改为构建并推送 `git.118im.com/im_team/olons-server:test`。
|
||||
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 资源
|
||||
|
||||
在 `im-deploy-config` 中新增独立应用:
|
||||
|
||||
+4
-4
@@ -31,12 +31,12 @@ Content-Type: application/json
|
||||
"username": "alice01",
|
||||
"phone": "13800138000",
|
||||
"password": "Secret123",
|
||||
"referral_code": "ABCD2345",
|
||||
"referral_code": "123456",
|
||||
"privacy_accepted": true
|
||||
}
|
||||
```
|
||||
|
||||
`referral_code` 可选;传入时必须匹配当前租户下正常会员的 `invite_code`。
|
||||
`referral_code` 必填,必须是 6 位数字,且匹配当前租户下正常会员的 `invite_code`。邀请链接可通过 `inviteCode`、`invite_code` 或 `referral_code` 参数预填注册表单。
|
||||
|
||||
## 4 登录
|
||||
|
||||
@@ -64,7 +64,7 @@ Content-Type: application/json
|
||||
"member_no": "MABCDEFGHIJKLMNOPQRST",
|
||||
"username": "alice01",
|
||||
"phone": "13800138000",
|
||||
"invite_code": "EFGH6789",
|
||||
"invite_code": "654321",
|
||||
"token": "...",
|
||||
"token_type": "Bearer",
|
||||
"expires_at": "2026-10-23T10:00:00Z"
|
||||
@@ -92,7 +92,7 @@ Authorization: Bearer <token>
|
||||
| `USERNAME_INVALID_FORMAT` | 400 | 账户名格式错误 |
|
||||
| `PHONE_INVALID_FORMAT` | 400 | 手机号格式错误 |
|
||||
| `PASSWORD_INVALID_FORMAT` | 400 | 密码格式错误 |
|
||||
| `REFERRAL_CODE_INVALID` | 400 | 推荐码无效 |
|
||||
| `REFERRAL_CODE_INVALID` | 400 | 邀请码为空、格式错误或不存在 |
|
||||
| `PRIVACY_POLICY_NOT_ACCEPTED` | 400 | 未同意隐私政策 |
|
||||
| `ACCOUNT_ALREADY_REGISTERED` | 409 | 账户名或手机号已注册 |
|
||||
| `INVALID_CREDENTIALS` | 401 | 手机号或密码错误 |
|
||||
|
||||
+37
-37
@@ -2,12 +2,12 @@
|
||||
|
||||
> 版本:V1.0
|
||||
> 更新日期:2026-10-09
|
||||
> 适用项目:`im_workbench`
|
||||
> 适用项目:`olons_server`
|
||||
> 数据源:MySQL `olons_admin` 中的 `hg_*` 业务表
|
||||
|
||||
## 1. 文档说明
|
||||
|
||||
本文档用于移动端与 Workbench 后端联调。内容依据《移动端任务与账户平台需求文档》、当前代码实现和现有数据库表结构整理。
|
||||
本文档用于移动端与 Olons Server 后端联调。内容依据《移动端任务与账户平台需求文档》、当前代码实现和现有数据库表结构整理。
|
||||
|
||||
接口分为两类:
|
||||
|
||||
@@ -167,7 +167,7 @@ Content-Type: application/json
|
||||
"username": "alice01",
|
||||
"phone": "13800138000",
|
||||
"password": "Secret123",
|
||||
"referral_code": "ABCD2345",
|
||||
"referral_code": "123456",
|
||||
"privacy_accepted": true
|
||||
}
|
||||
```
|
||||
@@ -177,7 +177,7 @@ Content-Type: application/json
|
||||
| `username` | string | 是 | 1–10 位小写字母或数字;后端去空格并转小写 |
|
||||
| `phone` | string | 是 | 中国大陆 11 位手机号,不带 `+86` |
|
||||
| `password` | string | 是 | 6–72 字节,不做 MD5 |
|
||||
| `referral_code` | string | 否 | 1–32 位字母或数字,必须匹配正常会员邀请码 |
|
||||
| `referral_code` | string | 是 | 6 位数字,必须匹配正常会员邀请码 |
|
||||
| `privacy_accepted` | boolean | 是 | 必须为 `true` |
|
||||
|
||||
注册事务同时写入:
|
||||
@@ -196,7 +196,7 @@ Content-Type: application/json
|
||||
"member_no": "MABCDEFGHIJKLMNOPQRST",
|
||||
"username": "alice01",
|
||||
"phone": "13800138000",
|
||||
"invite_code": "EFGH6789",
|
||||
"invite_code": "654321",
|
||||
"token": "random-base64url-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_at": "2026-10-23T10:00:00Z"
|
||||
@@ -241,7 +241,7 @@ Authorization: Bearer <token>
|
||||
| 400 | `USERNAME_INVALID_FORMAT` | 账户名格式错误 |
|
||||
| 400 | `PHONE_INVALID_FORMAT` | 手机号格式错误 |
|
||||
| 400 | `PASSWORD_INVALID_FORMAT` | 密码格式错误 |
|
||||
| 400 | `REFERRAL_CODE_INVALID` | 推荐码无效 |
|
||||
| 400 | `REFERRAL_CODE_INVALID` | 邀请码为空、格式错误或不存在 |
|
||||
| 400 | `PRIVACY_POLICY_NOT_ACCEPTED` | 未同意隐私政策 |
|
||||
| 401 | `INVALID_CREDENTIALS` | 手机号或密码错误 |
|
||||
| 409 | `ACCOUNT_ALREADY_REGISTERED` | 账户名、手机号或其他唯一字段冲突 |
|
||||
@@ -265,7 +265,7 @@ GET /v1/me
|
||||
"username": "alice01",
|
||||
"phone": "13800138000",
|
||||
"avatar_url": "",
|
||||
"invite_code": "EFGH6789",
|
||||
"invite_code": "654321",
|
||||
"level": {
|
||||
"id": 1,
|
||||
"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`:
|
||||
|
||||
@@ -581,7 +581,7 @@ POST /v1/task-orders/claim
|
||||
|
||||
成功返回订单摘要,包括 `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` | 说明 |
|
||||
| --- | --- | --- |
|
||||
@@ -642,15 +642,12 @@ POST /v1/task-orders/{order_id}/submissions
|
||||
```
|
||||
|
||||
```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`:凭证、审核状态与驳回原因;
|
||||
- `timeline`:创建、匹配、提交、审核、结算、完成时间点;
|
||||
- `ledgers`:以订单号或提交号关联的钱包流水;
|
||||
- `allowed_actions`:待处理为 `submit`、`contact_support`,审核中/超时/冻结/失败仅为 `contact_support`。
|
||||
- `allowed_actions`:待处理为 `submit`、`contact_support`,超时/冻结/失败仅为 `contact_support`;提交成功后不再提供重复提交操作。
|
||||
|
||||
### 7.6 任务统计
|
||||
|
||||
@@ -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` 和经过校验的支付指引。不通过客服聊天发送临时个人收款账户。
|
||||
|
||||
公开状态:`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. 收款账户和取出(已实现,沙箱)
|
||||
|
||||
@@ -870,36 +867,38 @@ GET /v1/wallet/ledgers?page=1&page_size=20&biz_type=task&direction=income
|
||||
```http
|
||||
GET /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
|
||||
{
|
||||
"account_type": "bank_card",
|
||||
"bank_code": "ICBC",
|
||||
"bank_name": "中国工商银行",
|
||||
"holder_name": "张三",
|
||||
"identity_no": "11010119900101123X",
|
||||
"account_no": "6222000000001234",
|
||||
"reserved_phone": "13800138000",
|
||||
"qr_attachment_id": 0,
|
||||
"trade_password": "654321"
|
||||
"wechat_qr_attachment_id": 101,
|
||||
"alipay_qr_attachment_id": 102
|
||||
}
|
||||
```
|
||||
|
||||
支持的账户类型:
|
||||
在“取出信息”页面,`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 位数字,空格和连字符会被移除 |
|
||||
| `digital_wallet` | `holder_name`、`account_no` | 通用数字钱包 |
|
||||
| `unionpay` | `holder_name`、`account_no` | 银联账户 |
|
||||
| `alipay` | `holder_name`、`account_no` | 后端固定渠道名称和编码 |
|
||||
| `wechat` | `holder_name`、`account_no` | 后端固定渠道名称和编码 |
|
||||
| `holder_name` | 是 | 2–64 个字符 |
|
||||
| `identity_no` | 是 | 15 位或 18 位身份证号,18 位末位允许 X |
|
||||
| `account_no` | 是 | 8–32 位数字,空格和连字符会被移除 |
|
||||
| `bank_name` | 是 | 2–100 个字符 |
|
||||
| `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=pending`,必须由后台核验为 `verified` 后才能用于取出。
|
||||
姓名、身份证号和银行卡号使用 AES-256-GCM 独立随机 nonce 加密,身份证号和银行卡号另存 HMAC-SHA256 检索哈希。列表只返回脱敏信息,绝不返回明文或密文。绑定不要求支付密码;支付密码只在取出提交时校验。当前产品不设置银行卡后台审核流程,绑定成功后直接写入 `verify_status=verified`,可立即用于取出。
|
||||
|
||||
列表项示例:
|
||||
|
||||
@@ -907,13 +906,14 @@ POST /v1/payment-accounts
|
||||
{
|
||||
"id": 1,
|
||||
"account_type": "bank_card",
|
||||
"bank_code": "ICBC",
|
||||
"bank_code": "BANK",
|
||||
"bank_name": "中国工商银行",
|
||||
"holder_mask": "张*",
|
||||
"mobile_mask": "138****8000",
|
||||
"identity_no_mask": "1101**********123X",
|
||||
"account_no_mask": "6222********1234",
|
||||
"qr_attachment_id": 0,
|
||||
"verify_status": "pending",
|
||||
"wechat_qr_attachment_id": 101,
|
||||
"alipay_qr_attachment_id": 102,
|
||||
"verify_status": "verified",
|
||||
"is_default": true,
|
||||
"created_at": "2026-10-09T10:00:00Z",
|
||||
"updated_at": "2026-10-09T10:00:00Z"
|
||||
|
||||
@@ -111,9 +111,9 @@
|
||||
| **状态** | **用户可见文案** | **允许操作** | **系统约束** |
|
||||
| --- | --- | --- | --- |
|
||||
| 匹配中 | 正在匹配任务 | 取消等待 | 只允许一个匹配请求 |
|
||||
| 待处理 | 请在截止时间前完成 | 查看详情、提交 | 倒计时与服务端时间一致 |
|
||||
| 处理中 | 已提交,等待确认 | 查看详情、联系客服 | 防重复提交 |
|
||||
| 已完成 | 本金与佣金已入账 | 查看流水 | 三笔或组合流水必须可核对 |
|
||||
| 待处理 | 请在截止时间前完成 | 查看详情、立即提交 | 倒计时与服务端时间一致 |
|
||||
| 处理中 | 系统正在结算 | 查看详情、联系客服 | 防重复提交;正常情况为瞬时状态 |
|
||||
| 已完成 | 佣金已入账 | 查看流水 | 订单、提交记录和佣金流水必须可核对 |
|
||||
| 超时或冻结 | 显示原因与处理路径 | 申诉、联系客服 | 需要人工复核和审计 |
|
||||
| 失败或取消 | 显示失败原因 | 重新领取 | 不得产生不一致余额 |
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module git.118im.com/im_team/im_workbench
|
||||
module git.118im.com/im_team/olons_server
|
||||
|
||||
go 1.26.0
|
||||
|
||||
@@ -15,6 +15,7 @@ require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
|
||||
@@ -19,6 +19,8 @@ github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxo
|
||||
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||
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/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/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -44,14 +44,14 @@ func (r *profileRepository) UpdateTradePassword(_ context.Context, _, _ int64, _
|
||||
func TestProfileMapsMemberCategory(t *testing.T) {
|
||||
repository := &profileRepository{overview: &store.MemberOverview{
|
||||
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}
|
||||
profile, err := service.Profile(context.Background(), 0, 7)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,17 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -36,9 +40,8 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
usernamePattern = regexp.MustCompile(`^[a-z0-9]{1,10}$`)
|
||||
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}$`)
|
||||
)
|
||||
|
||||
@@ -75,17 +78,18 @@ type Result struct {
|
||||
}
|
||||
|
||||
type Profile struct {
|
||||
MemberID int64 `json:"member_id"`
|
||||
MemberNo string `json:"member_no"`
|
||||
Username string `json:"username"`
|
||||
Phone string `json:"phone"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
Level ProfileLevel `json:"level"`
|
||||
MemberStatus string `json:"member_status"`
|
||||
MemberCategory string `json:"member_category"`
|
||||
CreditScore int `json:"credit_score"`
|
||||
Wallet ProfileWallet `json:"wallet"`
|
||||
MemberID int64 `json:"member_id"`
|
||||
MemberNo string `json:"member_no"`
|
||||
Username string `json:"username"`
|
||||
Phone string `json:"phone"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
InviteCode string `json:"invite_code"`
|
||||
HasTradePassword bool `json:"has_trade_password"`
|
||||
Level ProfileLevel `json:"level"`
|
||||
MemberStatus string `json:"member_status"`
|
||||
MemberCategory string `json:"member_category"`
|
||||
CreditScore int `json:"credit_score"`
|
||||
Wallet ProfileWallet `json:"wallet"`
|
||||
}
|
||||
|
||||
type ProfileLevel struct {
|
||||
@@ -142,10 +146,10 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, e
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
input.Username = strings.ToLower(strings.TrimSpace(input.Username))
|
||||
input.Username = strings.TrimSpace(input.Username)
|
||||
input.Phone = strings.TrimSpace(input.Phone)
|
||||
input.ReferralCode = strings.TrimSpace(input.ReferralCode)
|
||||
if !usernamePattern.MatchString(input.Username) {
|
||||
if !validMemberName(input.Username) {
|
||||
return nil, ErrUsernameInvalid
|
||||
}
|
||||
if !phonePattern.MatchString(input.Phone) {
|
||||
@@ -154,7 +158,7 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, e
|
||||
if len([]rune(input.Password)) < 6 || len([]byte(input.Password)) > 72 {
|
||||
return nil, ErrPasswordInvalid
|
||||
}
|
||||
if input.ReferralCode != "" && !referralPattern.MatchString(input.ReferralCode) {
|
||||
if !referralPattern.MatchString(input.ReferralCode) {
|
||||
return nil, ErrReferralInvalid
|
||||
}
|
||||
if !input.PrivacyAccepted {
|
||||
@@ -169,26 +173,53 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, e
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inviteCode, err := newPublicID("", 8)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, err := s.repository.RegisterAccount(ctx, store.AccountRegistration{
|
||||
TenantID: s.cfg.DefaultTenantID,
|
||||
MemberNo: memberNo,
|
||||
Username: input.Username,
|
||||
Mobile: input.Phone,
|
||||
PasswordHash: string(passwordHash),
|
||||
ReferralCode: input.ReferralCode,
|
||||
InviteCode: inviteCode,
|
||||
})
|
||||
if err != nil {
|
||||
for attempt := 0; attempt < 10; attempt++ {
|
||||
inviteCode, err := newInviteCode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, err := s.repository.RegisterAccount(ctx, store.AccountRegistration{
|
||||
TenantID: s.cfg.DefaultTenantID,
|
||||
MemberNo: memberNo,
|
||||
Username: input.Username,
|
||||
Mobile: input.Phone,
|
||||
PasswordHash: string(passwordHash),
|
||||
ReferralCode: input.ReferralCode,
|
||||
InviteCode: inviteCode,
|
||||
})
|
||||
if errors.Is(err, store.ErrInviteCodeExists) {
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, store.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) {
|
||||
@@ -238,6 +269,7 @@ func (s *Service) Profile(ctx context.Context, tenantID, memberID int64) (*Profi
|
||||
return &Profile{
|
||||
MemberID: row.MemberID, MemberNo: row.MemberNo, Username: row.Username,
|
||||
Phone: row.Mobile, AvatarURL: row.AvatarURL, InviteCode: row.InviteCode,
|
||||
HasTradePassword: row.HasTradePassword != 0,
|
||||
Level: ProfileLevel{ID: row.LevelID, Code: row.LevelCode, Name: row.LevelName,
|
||||
IconURL: row.LevelIconURL, CommissionRate: row.CommissionRate},
|
||||
MemberStatus: memberStatusName(row.MemberStatus), MemberCategory: memberCategoryName(row.MemberCategory),
|
||||
@@ -355,7 +387,7 @@ func (s *Service) issueSession(ctx context.Context, account *store.Account, devi
|
||||
|
||||
func (s *Service) loginRateKey(phone string) string {
|
||||
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:" + strconv.FormatInt(s.cfg.DefaultTenantID, 10) + ":" + hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func (s *Service) recordLoginFailure(ctx context.Context, key string) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct{}
|
||||
@@ -43,11 +43,13 @@ func TestRegisterValidation(t *testing.T) {
|
||||
input RegisterInput
|
||||
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: "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: "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 {
|
||||
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) {
|
||||
s := &Service{}
|
||||
key := s.loginRateKey("13800138000")
|
||||
|
||||
+20
-14
@@ -8,15 +8,18 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/im_workbench/internal/finance"
|
||||
"git.118im.com/im_team/im_workbench/internal/httpapi"
|
||||
"git.118im.com/im_team/im_workbench/internal/mall"
|
||||
"git.118im.com/im_team/im_workbench/internal/portal"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/im_workbench/internal/tasks"
|
||||
"git.118im.com/im_team/olons_server/internal/accounts"
|
||||
"git.118im.com/im_team/olons_server/internal/attachments"
|
||||
"git.118im.com/im_team/olons_server/internal/auth"
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/finance"
|
||||
"git.118im.com/im_team/olons_server/internal/httpapi"
|
||||
"git.118im.com/im_team/olons_server/internal/imclient"
|
||||
"git.118im.com/im_team/olons_server/internal/mall"
|
||||
"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/redis/go-redis/v9"
|
||||
)
|
||||
@@ -26,6 +29,7 @@ type App struct {
|
||||
redis *redis.Client
|
||||
api *httpapi.API
|
||||
frontendPath string
|
||||
upload config.UploadConfig
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) (*App, error) {
|
||||
@@ -49,14 +53,16 @@ func New(cfg *config.Config) (*App, error) {
|
||||
taskService := tasks.New(st)
|
||||
mallService := mall.New(st)
|
||||
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
|
||||
attachmentService := attachments.New(st, cfg.Upload)
|
||||
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: cfg.Upload}, nil
|
||||
}
|
||||
|
||||
func (a *App) Handler() http.Handler {
|
||||
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() != "" {
|
||||
mux.Handle("/admin", frontendHandler(a.frontendDir()))
|
||||
mux.Handle("/admin/", frontendHandler(a.frontendDir()))
|
||||
mux.Handle("/", frontendHandler(a.frontendDir()))
|
||||
}
|
||||
mux.HandleFunc("/healthz", a.api.Healthz)
|
||||
mux.Handle("/metrics", promhttp.Handler())
|
||||
@@ -65,7 +71,7 @@ func (a *App) Handler() http.Handler {
|
||||
}
|
||||
|
||||
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 strings.TrimSpace(a.frontendPath)
|
||||
@@ -75,7 +81,7 @@ func frontendHandler(dir string) http.Handler {
|
||||
files := http.FileServer(http.Dir(dir))
|
||||
index := filepath.Join(dir, "index.html")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/admin")
|
||||
path := r.URL.Path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package attachments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func New(repository Repository, cfg config.UploadConfig) *Service {
|
||||
return &Service{repository: repository, cfg: cfg}
|
||||
}
|
||||
|
||||
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
|
||||
if err = os.MkdirAll(s.cfg.Dir, 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := filepath.Join(s.cfg.Dir, fileName)
|
||||
if err = os.WriteFile(path, data, 0o640); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
digest := md5.Sum(data)
|
||||
publicPath := strings.TrimRight(s.cfg.PublicPath, "/") + "/" + fileName
|
||||
attachment, err := s.repository.CreateAttachment(ctx, store.AttachmentCreation{
|
||||
MemberID: memberID, Name: filepath.Base(name), MimeType: mimeType, Path: path,
|
||||
URL: publicPath, Size: int64(len(data)), Ext: strings.TrimPrefix(ext, "."), MD5: hex.EncodeToString(digest[:]),
|
||||
})
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
return nil, err
|
||||
}
|
||||
return attachment, nil
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"strings"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -170,7 +170,7 @@ func (a *Authenticator) validateMemberSession(ctx context.Context, session *Sess
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -9,18 +9,18 @@ import (
|
||||
|
||||
func TestSessionKeyFromToken(t *testing.T) {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionKeyFromTokenRejectsMalformedToken(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+72
-19
@@ -18,10 +18,29 @@ type Config struct {
|
||||
MySQL MySQLConfig `json:"mysql"`
|
||||
Redis RedisConfig `json:"redis"`
|
||||
Auth AuthConfig `json:"auth"`
|
||||
Upload UploadConfig `json:"upload"`
|
||||
IM IMConfig `json:"im"`
|
||||
ReadTimeout 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 {
|
||||
Dir string `json:"dir"`
|
||||
PublicPath string `json:"public_path"`
|
||||
}
|
||||
|
||||
type MySQLConfig struct {
|
||||
DSN string `json:"dsn"`
|
||||
MaxOpenConns int `json:"max_open_conns"`
|
||||
@@ -42,13 +61,14 @@ type AuthConfig struct {
|
||||
DefaultTenantID int64 `json:"default_tenant_id"`
|
||||
DataEncryptionKey string `json:"data_encryption_key"`
|
||||
LookupHMACKey string `json:"lookup_hmac_key"`
|
||||
FinanceMasterKey string `json:"finance_master_key"`
|
||||
AllowDevHeaderAuth bool `json:"allow_dev_header_auth"`
|
||||
RequiredAPIKey bool `json:"required_api_key"`
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -75,34 +95,53 @@ func defaultConfig() *Config {
|
||||
},
|
||||
Redis: RedisConfig{Addr: "127.0.0.1:6379"},
|
||||
Auth: AuthConfig{
|
||||
SessionKeyPattern: "workbench:session:token:%s",
|
||||
SessionKeyPattern: "olons_server:session:token:%s",
|
||||
SessionTTLSeconds: 14 * 24 * 60 * 60,
|
||||
DefaultTenantID: 0,
|
||||
RequiredAPIKey: true,
|
||||
},
|
||||
Upload: UploadConfig{Dir: "storage/uploads", PublicPath: "/uploads"},
|
||||
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: "allrich", APIKey: "AQAAAAABAAChdu8_yk0OxZcId6FnwYNS",
|
||||
InternalService: "olons-server", Timeout: 8 * time.Second,
|
||||
},
|
||||
ReadTimeout: 8 * time.Second,
|
||||
WriteTimeout: 8 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func applyEnv(cfg *Config) {
|
||||
setString(&cfg.Listen, "WORKBENCH_LISTEN")
|
||||
setString(&cfg.FrontendDir, "WORKBENCH_FRONTEND_DIR")
|
||||
setString(&cfg.MySQL.DSN, "WORKBENCH_MYSQL_DSN")
|
||||
setInt(&cfg.MySQL.MaxOpenConns, "WORKBENCH_MYSQL_MAX_OPEN_CONNS")
|
||||
setInt(&cfg.MySQL.MaxIdleConns, "WORKBENCH_MYSQL_MAX_IDLE_CONNS")
|
||||
setString(&cfg.Redis.Addr, "WORKBENCH_REDIS_ADDR")
|
||||
setString(&cfg.Redis.Username, "WORKBENCH_REDIS_USERNAME")
|
||||
setString(&cfg.Redis.Password, "WORKBENCH_REDIS_PASSWORD")
|
||||
setInt(&cfg.Redis.DB, "WORKBENCH_REDIS_DB")
|
||||
setString(&cfg.Auth.SessionKeyPattern, "WORKBENCH_SESSION_KEY_PATTERN")
|
||||
setInt(&cfg.Auth.SessionTTLSeconds, "WORKBENCH_SESSION_TTL_SECONDS")
|
||||
setInt64(&cfg.Auth.DefaultTenantID, "WORKBENCH_DEFAULT_TENANT_ID")
|
||||
setString(&cfg.Auth.DataEncryptionKey, "WORKBENCH_DATA_ENCRYPTION_KEY")
|
||||
setString(&cfg.Auth.LookupHMACKey, "WORKBENCH_LOOKUP_HMAC_KEY")
|
||||
setBool(&cfg.Auth.AllowDevHeaderAuth, "WORKBENCH_ALLOW_DEV_HEADER_AUTH")
|
||||
setBool(&cfg.Auth.RequiredAPIKey, "WORKBENCH_REQUIRED_API_KEY")
|
||||
if keys := strings.TrimSpace(os.Getenv("WORKBENCH_API_KEYS")); keys != "" {
|
||||
setString(&cfg.Listen, "OLONS_SERVER_LISTEN")
|
||||
setString(&cfg.FrontendDir, "OLONS_SERVER_FRONTEND_DIR")
|
||||
setString(&cfg.MySQL.DSN, "OLONS_SERVER_MYSQL_DSN")
|
||||
setInt(&cfg.MySQL.MaxOpenConns, "OLONS_SERVER_MYSQL_MAX_OPEN_CONNS")
|
||||
setInt(&cfg.MySQL.MaxIdleConns, "OLONS_SERVER_MYSQL_MAX_IDLE_CONNS")
|
||||
setString(&cfg.Redis.Addr, "OLONS_SERVER_REDIS_ADDR")
|
||||
setString(&cfg.Redis.Username, "OLONS_SERVER_REDIS_USERNAME")
|
||||
setString(&cfg.Redis.Password, "OLONS_SERVER_REDIS_PASSWORD")
|
||||
setInt(&cfg.Redis.DB, "OLONS_SERVER_REDIS_DB")
|
||||
setString(&cfg.Auth.SessionKeyPattern, "OLONS_SERVER_SESSION_KEY_PATTERN")
|
||||
setInt(&cfg.Auth.SessionTTLSeconds, "OLONS_SERVER_SESSION_TTL_SECONDS")
|
||||
setInt64(&cfg.Auth.DefaultTenantID, "OLONS_SERVER_DEFAULT_TENANT_ID")
|
||||
setString(&cfg.Auth.DataEncryptionKey, "OLONS_SERVER_DATA_ENCRYPTION_KEY")
|
||||
setString(&cfg.Auth.LookupHMACKey, "OLONS_SERVER_LOOKUP_HMAC_KEY")
|
||||
setString(&cfg.Auth.FinanceMasterKey, "OLONS_SERVER_FINANCE_MASTER_KEY")
|
||||
setBool(&cfg.Auth.AllowDevHeaderAuth, "OLONS_SERVER_ALLOW_DEV_HEADER_AUTH")
|
||||
setBool(&cfg.Auth.RequiredAPIKey, "OLONS_SERVER_REQUIRED_API_KEY")
|
||||
setString(&cfg.Upload.Dir, "OLONS_SERVER_UPLOAD_DIR")
|
||||
setString(&cfg.Upload.PublicPath, "OLONS_SERVER_UPLOAD_PUBLIC_PATH")
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +172,12 @@ func (c *Config) validate() error {
|
||||
if c.Auth.DefaultTenantID < 0 {
|
||||
return errors.New("default tenant id must not be negative")
|
||||
}
|
||||
if strings.TrimSpace(c.Upload.Dir) == "" || !strings.HasPrefix(c.Upload.PublicPath, "/") {
|
||||
return errors.New("upload config is invalid")
|
||||
}
|
||||
if c.IM.Timeout <= 0 {
|
||||
c.IM.Timeout = 8 * time.Second
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -167,6 +212,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 {
|
||||
parts := strings.Split(value, ",")
|
||||
items := make([]string, 0, len(parts))
|
||||
|
||||
+209
-112
@@ -19,8 +19,8 @@ import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -34,17 +34,17 @@ const (
|
||||
|
||||
var (
|
||||
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}$`)
|
||||
identityNoPattern = regexp.MustCompile(`^[1-9][0-9]{14}(?:[0-9]{2}[0-9X])?$`)
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
CurrentSiteConfig(context.Context, int64) (json.RawMessage, error)
|
||||
AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, 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)
|
||||
UpdatePaymentAccount(context.Context, int64, store.PaymentAccountCreation) (*store.PaymentAccount, error)
|
||||
PaymentAccountForWithdrawal(context.Context, int64, int64, int64) (*store.PaymentAccount, error)
|
||||
ActiveWithdrawalChannel(context.Context, int64, []int) (*store.WithdrawalChannel, error)
|
||||
WithdrawalWallet(context.Context, int64, int64) (*store.WithdrawalWallet, error)
|
||||
@@ -68,29 +68,47 @@ type Service struct {
|
||||
}
|
||||
|
||||
type PaymentAccountInput struct {
|
||||
AccountType string `json:"account_type"`
|
||||
BankCode string `json:"bank_code"`
|
||||
BankName string `json:"bank_name"`
|
||||
HolderName string `json:"holder_name"`
|
||||
AccountNo string `json:"account_no"`
|
||||
ReservedPhone string `json:"reserved_phone"`
|
||||
QRAttachmentID int64 `json:"qr_attachment_id"`
|
||||
TradePassword string `json:"trade_password"`
|
||||
ID int64 `json:"id"`
|
||||
BankName string `json:"bank_name"`
|
||||
HolderName string `json:"holder_name"`
|
||||
IdentityNo string `json:"identity_no"`
|
||||
AccountNo string `json:"account_no"`
|
||||
DigitalRMBCode string `json:"digital_rmb_code"`
|
||||
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
|
||||
AlipayQRAttachmentID int64 `json:"alipay_qr_attachment_id"`
|
||||
}
|
||||
|
||||
type PaymentAccount struct {
|
||||
ID int64 `json:"id"`
|
||||
AccountType string `json:"account_type"`
|
||||
BankCode string `json:"bank_code"`
|
||||
BankName string `json:"bank_name"`
|
||||
HolderMask string `json:"holder_mask"`
|
||||
MobileMask string `json:"mobile_mask"`
|
||||
AccountNoMask string `json:"account_no_mask"`
|
||||
QRAttachmentID int64 `json:"qr_attachment_id"`
|
||||
VerifyStatus string `json:"verify_status"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
AccountType string `json:"account_type"`
|
||||
BankCode string `json:"bank_code"`
|
||||
BankName string `json:"bank_name"`
|
||||
HolderMask string `json:"holder_mask"`
|
||||
IdentityNoMask string `json:"identity_no_mask"`
|
||||
AccountNoMask string `json:"account_no_mask"`
|
||||
DigitalRMBCodeMask string `json:"digital_rmb_code_mask"`
|
||||
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
|
||||
AlipayQRAttachmentID int64 `json:"alipay_qr_attachment_id"`
|
||||
VerifyStatus string `json:"verify_status"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
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"`
|
||||
IdentityNo string `json:"identity_no"`
|
||||
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 {
|
||||
@@ -235,10 +253,18 @@ type WalletLedgerPage struct {
|
||||
|
||||
func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service {
|
||||
service := &Service{repository: repository, redis: redisClient, now: time.Now}
|
||||
encryptionKey, encryptionErr := decodeKey(cfg.DataEncryptionKey)
|
||||
lookupKey, lookupErr := decodeKey(cfg.LookupHMACKey)
|
||||
if encryptionErr != nil || len(encryptionKey) != 32 || lookupErr != nil || len(lookupKey) < 32 {
|
||||
return service
|
||||
masterKey := strings.TrimSpace(cfg.FinanceMasterKey)
|
||||
var encryptionKey, lookupKey []byte
|
||||
if len([]byte(masterKey)) >= 32 {
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -254,6 +280,12 @@ func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig
|
||||
return service
|
||||
}
|
||||
|
||||
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) {
|
||||
if s.repository == nil {
|
||||
return nil, ErrUnavailable
|
||||
@@ -269,41 +301,57 @@ func (s *Service) PaymentAccounts(ctx context.Context, tenantID, memberID int64)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID int64, input PaymentAccountInput) (*PaymentAccount, error) {
|
||||
if !s.ready {
|
||||
func (s *Service) PaymentAccountDetails(ctx context.Context, tenantID, memberID int64) ([]PaymentAccountDetail, error) {
|
||||
if s.repository == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
accountType, err := normalizePaymentAccount(&input)
|
||||
rows, err := s.repository.PaymentAccountDetails(ctx, tenantID, memberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
security, err := s.verifyTradePassword(ctx, tenantID, memberID, input.TradePassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
holderCipher, err := s.encrypt(input.HolderName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accountCipher, err := s.encrypt(input.AccountNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mobileCipher := ""
|
||||
if input.ReservedPhone != "" {
|
||||
mobileCipher, err = s.encrypt("+86" + input.ReservedPhone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
items := make([]PaymentAccountDetail, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
holderName, accountNo := row.HolderMask, row.AccountNoMask
|
||||
identityNo, digitalRMBCode := row.IdentityNoMask, row.DigitalRMBCodeMask
|
||||
masked := true
|
||||
if s.ready {
|
||||
decryptedHolder, holderErr := s.decrypt(row.HolderCipher)
|
||||
decryptedAccount, accountErr := s.decrypt(row.AccountNoCipher)
|
||||
if holderErr == nil && accountErr == nil {
|
||||
holderName, accountNo, masked = decryptedHolder, decryptedAccount, false
|
||||
if row.IdentityNoCipher != "" {
|
||||
if value, decryptErr := s.decrypt(row.IdentityNoCipher); decryptErr == nil {
|
||||
identityNo = value
|
||||
} else {
|
||||
masked = true
|
||||
}
|
||||
}
|
||||
if row.DigitalRMBCodeCipher != "" {
|
||||
if value, decryptErr := s.decrypt(row.DigitalRMBCodeCipher); decryptErr == nil {
|
||||
digitalRMBCode = value
|
||||
} else {
|
||||
masked = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items = append(items, PaymentAccountDetail{
|
||||
ID: row.ID, BankName: row.BankName, HolderName: holderName,
|
||||
IdentityNo: identityNo, 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{
|
||||
TenantID: tenantID, MemberID: memberID, ExpectedTradePasswordHash: security.TradePasswordHash,
|
||||
AccountType: accountType, BankCode: input.BankCode, BankName: input.BankName,
|
||||
HolderCipher: holderCipher, HolderMask: maskName(input.HolderName), MobileCipher: mobileCipher,
|
||||
MobileMask: maskPhone(input.ReservedPhone), AccountNoCipher: accountCipher,
|
||||
AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo),
|
||||
QRAttachmentID: input.QRAttachmentID,
|
||||
})
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID int64, input PaymentAccountInput) (*PaymentAccount, error) {
|
||||
data, err := s.paymentAccountCreation(tenantID, memberID, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row, err := s.repository.CreatePaymentAccount(ctx, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -311,6 +359,63 @@ func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID i
|
||||
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
|
||||
}
|
||||
identityNoCipher, err := s.encrypt(input.IdentityNo)
|
||||
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),
|
||||
IdentityNoCipher: identityNoCipher, IdentityNoHash: s.lookupHash(input.IdentityNo),
|
||||
IdentityNoMask: maskIdentityNo(input.IdentityNo), 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) {
|
||||
if !s.ready {
|
||||
return nil, ErrUnavailable
|
||||
@@ -349,9 +454,16 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
|
||||
if err != nil {
|
||||
return nil, store.ErrPaymentAccountInvalid
|
||||
}
|
||||
reservedPhone := ""
|
||||
if account.MobileCipher != "" {
|
||||
reservedPhone, err = s.decrypt(account.MobileCipher)
|
||||
identityNo := ""
|
||||
if account.IdentityNoCipher != "" {
|
||||
identityNo, err = s.decrypt(account.IdentityNoCipher)
|
||||
if err != nil {
|
||||
return nil, store.ErrPaymentAccountInvalid
|
||||
}
|
||||
}
|
||||
digitalRMBCode := ""
|
||||
if account.DigitalRMBCodeCipher != "" {
|
||||
digitalRMBCode, err = s.decrypt(account.DigitalRMBCodeCipher)
|
||||
if err != nil {
|
||||
return nil, store.ErrPaymentAccountInvalid
|
||||
}
|
||||
@@ -359,8 +471,10 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
|
||||
snapshot, err := json.Marshal(map[string]any{
|
||||
"account_type": accountTypeName(account.AccountType), "bank_code": account.BankCode,
|
||||
"bank_name": account.BankName, "holder_name": holderName,
|
||||
"reserved_phone": reservedPhone, "account_no": accountNo,
|
||||
"qr_attachment_id": account.QRAttachmentID,
|
||||
"identity_no": identityNo, "account_no": accountNo,
|
||||
"digital_rmb_code": digitalRMBCode,
|
||||
"wechat_qr_attachment_id": account.WechatQRAttachmentID,
|
||||
"alipay_qr_attachment_id": account.AlipayQRAttachmentID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -375,10 +489,14 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
|
||||
WithdrawalNo: withdrawalNo, PaymentAccountID: account.ID,
|
||||
ExpectedAccountType: account.AccountType, ExpectedBankCode: account.BankCode,
|
||||
ExpectedBankName: account.BankName, ExpectedHolderCipher: account.HolderCipher,
|
||||
ExpectedMobileCipher: account.MobileCipher, ExpectedAccountNoCipher: account.AccountNoCipher,
|
||||
ExpectedAccountNoHash: account.AccountNoHash, ExpectedQRAttachmentID: account.QRAttachmentID,
|
||||
Channel: *channel,
|
||||
RequestAmount: quote.RequestAmount, FeeAmount: quote.FeeAmount, ActualAmount: quote.ActualAmount,
|
||||
ExpectedIdentityNoCipher: account.IdentityNoCipher, ExpectedIdentityNoHash: account.IdentityNoHash,
|
||||
ExpectedAccountNoCipher: account.AccountNoCipher, ExpectedAccountNoHash: account.AccountNoHash,
|
||||
ExpectedDigitalRMBCodeCipher: account.DigitalRMBCodeCipher,
|
||||
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,
|
||||
BankName: account.BankName, HolderMask: account.HolderMask,
|
||||
AvailableLedgerNo: deterministicNumber("LA", tenantID, memberID, idempotencyKey),
|
||||
@@ -695,7 +813,7 @@ func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID in
|
||||
if !tradePasswordPattern.MatchString(strings.TrimSpace(password)) {
|
||||
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()
|
||||
if err != nil && !errors.Is(err, redis.Nil) {
|
||||
return nil, ErrUnavailable
|
||||
@@ -725,58 +843,30 @@ func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID in
|
||||
return security, nil
|
||||
}
|
||||
|
||||
func normalizePaymentAccount(input *PaymentAccountInput) (int, error) {
|
||||
input.AccountType = strings.ToLower(strings.TrimSpace(input.AccountType))
|
||||
input.BankCode = strings.ToUpper(strings.TrimSpace(input.BankCode))
|
||||
func normalizePaymentAccount(input *PaymentAccountInput) error {
|
||||
input.BankName = strings.TrimSpace(input.BankName)
|
||||
input.HolderName = strings.TrimSpace(input.HolderName)
|
||||
input.AccountNo = strings.TrimSpace(input.AccountNo)
|
||||
input.ReservedPhone = strings.TrimSpace(input.ReservedPhone)
|
||||
if !validHumanName(input.HolderName) || (input.ReservedPhone != "" && !phonePattern.MatchString(input.ReservedPhone)) || input.QRAttachmentID < 0 {
|
||||
return 0, ErrAccountFieldsInvalid
|
||||
}
|
||||
switch input.AccountType {
|
||||
case "bank_card":
|
||||
input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(input.AccountNo)
|
||||
if !bankCodePattern.MatchString(input.BankCode) || utf8.RuneCountInString(input.BankName) < 2 ||
|
||||
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
|
||||
input.IdentityNo = strings.ToUpper(strings.NewReplacer(" ", "", "-", "").Replace(input.IdentityNo))
|
||||
input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.AccountNo))
|
||||
input.DigitalRMBCode = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.DigitalRMBCode))
|
||||
if !validHumanName(input.HolderName) || !identityNoPattern.MatchString(input.IdentityNo) ||
|
||||
utf8.RuneCountInString(input.BankName) < 2 || utf8.RuneCountInString(input.BankName) > 100 ||
|
||||
len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) ||
|
||||
(input.DigitalRMBCode != "" && (len(input.DigitalRMBCode) < 6 || len(input.DigitalRMBCode) > 32 || !allDigits(input.DigitalRMBCode))) ||
|
||||
input.WechatQRAttachmentID <= 0 || input.AlipayQRAttachmentID <= 0 ||
|
||||
input.WechatQRAttachmentID == input.AlipayQRAttachmentID {
|
||||
return ErrAccountFieldsInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func paymentAccountFromStore(row store.PaymentAccount) PaymentAccount {
|
||||
return PaymentAccount{ID: row.ID, AccountType: accountTypeName(row.AccountType), BankCode: row.BankCode,
|
||||
BankName: row.BankName, HolderMask: row.HolderMask, MobileMask: row.MobileMask,
|
||||
AccountNoMask: row.AccountNoMask, QRAttachmentID: row.QRAttachmentID,
|
||||
VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1,
|
||||
BankName: row.BankName, HolderMask: row.HolderMask, IdentityNoMask: row.IdentityNoMask,
|
||||
AccountNoMask: row.AccountNoMask, DigitalRMBCodeMask: row.DigitalRMBCodeMask,
|
||||
WechatQRAttachmentID: row.WechatQRAttachmentID,
|
||||
AlipayQRAttachmentID: row.AlipayQRAttachmentID,
|
||||
VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1,
|
||||
CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||
}
|
||||
|
||||
@@ -1023,6 +1113,13 @@ func maskAccount(value string) string {
|
||||
return string(runes[:prefix]) + strings.Repeat("*", len(runes)-prefix-4) + string(runes[len(runes)-4:])
|
||||
}
|
||||
|
||||
func maskIdentityNo(value string) string {
|
||||
if len(value) <= 8 {
|
||||
return strings.Repeat("*", len(value))
|
||||
}
|
||||
return value[:4] + strings.Repeat("*", len(value)-8) + value[len(value)-4:]
|
||||
}
|
||||
|
||||
func validHumanName(value string) bool {
|
||||
count := utf8.RuneCountInString(value)
|
||||
if count < 2 || count > 64 {
|
||||
|
||||
@@ -7,13 +7,15 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
config json.RawMessage
|
||||
account *store.PaymentAccount
|
||||
details []store.PaymentAccount
|
||||
channel *store.WithdrawalChannel
|
||||
wallet *store.WithdrawalWallet
|
||||
orders []store.WithdrawalOrder
|
||||
@@ -23,6 +25,24 @@ type fakeRepository struct {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
return f.account, nil
|
||||
}
|
||||
@@ -60,7 +86,7 @@ func (f *fakeRepository) CreateRecharge(_ context.Context, input store.RechargeC
|
||||
f.createdRecharge = input
|
||||
return &store.RechargeOrder{ID: 8, RechargeNo: input.RechargeNo, ChannelID: input.ChannelID,
|
||||
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) {
|
||||
return &store.RechargePage{Items: f.rechargeOrders, Total: int64(len(f.rechargeOrders))}, nil
|
||||
@@ -101,15 +127,118 @@ func TestQuoteFailsClosed(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNormalizePaymentAccount(t *testing.T) {
|
||||
input := PaymentAccountInput{AccountType: "bank_card", BankCode: "icbc", BankName: "中国工商银行",
|
||||
HolderName: "张三", AccountNo: "6222 0000-0000 1234", ReservedPhone: "13800138000"}
|
||||
accountType, err := normalizePaymentAccount(&input)
|
||||
input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三",
|
||||
IdentityNo: "11010119900101123X", AccountNo: "6222 0000-0000 1234",
|
||||
DigitalRMBCode: "1234 5678",
|
||||
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9}
|
||||
if err := normalizePaymentAccount(&input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if input.IdentityNo != "11010119900101123X" || input.AccountNo != "6222000000001234" ||
|
||||
input.DigitalRMBCode != "12345678" ||
|
||||
maskAccount(input.AccountNo) != "6222********1234" || maskIdentityNo(input.IdentityNo) != "1101**********123X" {
|
||||
t.Fatalf("unexpected normalized input: %#v", input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePaymentAccountAllowsEmptyDigitalRMBCode(t *testing.T) {
|
||||
input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三",
|
||||
IdentityNo: "11010119900101123X", 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("张三")
|
||||
identity, _ := service.encrypt("11010119900101123X")
|
||||
account, _ := service.encrypt("6222000000001234")
|
||||
digital, _ := service.encrypt("123456")
|
||||
repository.details = []store.PaymentAccount{{
|
||||
ID: 7, BankName: "中国工商银行", HolderCipher: holder, IdentityNoCipher: identity,
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if accountType != 1 || input.BankCode != "ICBC" || input.AccountNo != "6222000000001234" ||
|
||||
maskAccount(input.AccountNo) != "6222********1234" {
|
||||
t.Fatalf("unexpected normalized input: %#v", input)
|
||||
if len(items) != 1 || items[0].HolderName != "张三" || items[0].IdentityNo != "11010119900101123X" ||
|
||||
items[0].AccountNo != "6222000000001234" || items[0].DigitalRMBCode != "123456" ||
|
||||
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 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: "李四", IdentityNo: "11010119900101123X",
|
||||
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].IdentityNo != "1101**********123X" || 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: "张三", IdentityNo: "11010119900101123X",
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +267,7 @@ func TestCreateRechargeValidatesChannelAndMapsOrder(t *testing.T) {
|
||||
if err != nil {
|
||||
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" {
|
||||
t.Fatalf("unexpected recharge: %#v, creation: %#v", result, repository.createdRecharge)
|
||||
}
|
||||
@@ -219,3 +348,16 @@ func TestEncryptionRoundTrip(t *testing.T) {
|
||||
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"
|
||||
"strings"
|
||||
|
||||
accountsvc "git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
accountsvc "git.118im.com/im_team/olons_server/internal/accounts"
|
||||
"git.118im.com/im_team/olons_server/internal/auth"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
type accountRegisterRequest struct {
|
||||
|
||||
+24
-15
@@ -3,25 +3,29 @@ package httpapi
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/finance"
|
||||
"git.118im.com/im_team/im_workbench/internal/mall"
|
||||
"git.118im.com/im_team/im_workbench/internal/portal"
|
||||
"git.118im.com/im_team/im_workbench/internal/tasks"
|
||||
"git.118im.com/im_team/olons_server/internal/accounts"
|
||||
"git.118im.com/im_team/olons_server/internal/attachments"
|
||||
"git.118im.com/im_team/olons_server/internal/auth"
|
||||
"git.118im.com/im_team/olons_server/internal/finance"
|
||||
"git.118im.com/im_team/olons_server/internal/mall"
|
||||
"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 {
|
||||
auth *auth.Authenticator
|
||||
accounts *accounts.Service
|
||||
portal *portal.Service
|
||||
tasks *tasks.Service
|
||||
mall *mall.Service
|
||||
finance *finance.Service
|
||||
auth *auth.Authenticator
|
||||
accounts *accounts.Service
|
||||
portal *portal.Service
|
||||
tasks *tasks.Service
|
||||
mall *mall.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 {
|
||||
return &API{auth: authenticator, accounts: accountService, portal: portalService, tasks: taskService, mall: mallService, finance: financeService}
|
||||
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, attachments: attachmentService, support: supportService}
|
||||
}
|
||||
|
||||
func (a *API) Register(mux *http.ServeMux) {
|
||||
@@ -38,7 +42,10 @@ func (a *API) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/v1/member-levels", a.memberLevels)
|
||||
mux.HandleFunc("/v1/leaderboard", a.leaderboard)
|
||||
mux.HandleFunc("/v1/customer-services", a.customerServices)
|
||||
mux.HandleFunc("/v1/customer-services/", a.customerServiceResource)
|
||||
mux.HandleFunc("/v1/notices", a.notices)
|
||||
mux.HandleFunc("/v1/notices/popup", a.popupNotice)
|
||||
mux.HandleFunc("/v1/notices/", a.noticeResource)
|
||||
mux.HandleFunc("/v1/task-halls", a.taskHalls)
|
||||
mux.HandleFunc("/v1/tasks/eligibility", a.taskEligibility)
|
||||
mux.HandleFunc("/v1/tasks/stats", a.taskStats)
|
||||
@@ -48,6 +55,8 @@ func (a *API) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/v1/manual-products", a.manualProducts)
|
||||
mux.HandleFunc("/v1/manual-products/", a.manualProduct)
|
||||
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/recharge/channels", a.rechargeChannels)
|
||||
mux.HandleFunc("/v1/recharge-orders", a.rechargeOrders)
|
||||
@@ -58,5 +67,5 @@ func (a *API) Register(mux *http.ServeMux) {
|
||||
}
|
||||
|
||||
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"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/finance"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/auth"
|
||||
"git.118im.com/im_team/olons_server/internal/finance"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
func (a *API) paymentAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||
methodNotAllowed(w, r, http.MethodGet, http.MethodPost)
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost && r.Method != http.MethodPut {
|
||||
methodNotAllowed(w, r, http.MethodGet, http.MethodPost, http.MethodPut)
|
||||
return
|
||||
}
|
||||
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) {
|
||||
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 {
|
||||
writeFinanceError(w, r, err)
|
||||
return
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/mall"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/mall"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
func (a *API) manualProducts(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -3,15 +3,19 @@ package httpapi
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/portal"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/portal"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
var publicContentKeys = map[string]struct{}{
|
||||
"privacy_policy": {},
|
||||
"user_agreement": {},
|
||||
"privacy_policy": {},
|
||||
"user_agreement": {},
|
||||
"platform_intro": {},
|
||||
"platform_introduction": {},
|
||||
"frequently_asked_questions": {},
|
||||
}
|
||||
|
||||
func (a *API) home(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -187,6 +191,56 @@ func (a *API) notices(w http.ResponseWriter, r *http.Request) {
|
||||
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) 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 {
|
||||
if a.portal != nil {
|
||||
return true
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
accountsvc "git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
accountsvc "git.118im.com/im_team/olons_server/internal/accounts"
|
||||
"git.118im.com/im_team/olons_server/internal/auth"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
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"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/auth"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/im_workbench/internal/tasks"
|
||||
"git.118im.com/im_team/olons_server/internal/auth"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/tasks"
|
||||
)
|
||||
|
||||
type taskClaimRequest struct {
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
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
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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))
|
||||
if err = conn.WriteJSON(map[string]any{"login": map[string]any{
|
||||
"id": loginID, "scheme": "basic", "secret": secret,
|
||||
"login_type": 0, "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": "Olons客服系统凭据同步",
|
||||
}, 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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,151 @@
|
||||
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 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 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"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/accounts"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -22,6 +22,8 @@ type Repository interface {
|
||||
PublishedContent(context.Context, int64, string) (*store.Content, error)
|
||||
ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error)
|
||||
ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error)
|
||||
LatestPopupNotice(context.Context, int64, int64) (*store.MemberNotice, error)
|
||||
MarkMemberNoticeRead(context.Context, int64, int64, int64) error
|
||||
MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error)
|
||||
MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error)
|
||||
TaskLeaderboard(context.Context, int64, int) ([]store.LeaderboardMember, error)
|
||||
@@ -51,6 +53,7 @@ type Notice struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
BodyHTML string `json:"body_html,omitempty"`
|
||||
SourceType string `json:"source_type"`
|
||||
StartAt string `json:"start_at"`
|
||||
EndAt string `json:"end_at"`
|
||||
@@ -124,6 +127,7 @@ type CustomerService struct {
|
||||
ServiceURL string `json:"service_url"`
|
||||
BackupContact string `json:"backup_contact"`
|
||||
PrivacyNotice string `json:"privacy_notice"`
|
||||
IMEnabled bool `json:"im_enabled"`
|
||||
}
|
||||
|
||||
func New(repository Repository) *Service {
|
||||
@@ -203,11 +207,42 @@ func (s *Service) Notices(ctx context.Context, tenantID, memberID int64, page, p
|
||||
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) 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) {
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
if !contentKeyPattern.MatchString(key) {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -216,7 +251,11 @@ func (s *Service) Content(ctx context.Context, tenantID int64, key string) (*Con
|
||||
if row.AssetIDs.Valid && strings.TrimSpace(row.AssetIDs.String) != "" {
|
||||
_ = 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,
|
||||
AssetIDs: assetIDs, Version: row.Version, PublishedAt: row.PublishedAt}, nil
|
||||
}
|
||||
@@ -266,6 +305,7 @@ func (s *Service) CustomerServices(ctx context.Context, tenantID int64) ([]Custo
|
||||
OnlineStatus: customerServiceStatus(row.OnlineStatus), ServiceURL: safeExternalURL(row.ServiceURL),
|
||||
BackupContact: row.BackupContact,
|
||||
PrivacyNotice: "请勿在客服会话中发送登录密码、支付密码或短信验证码",
|
||||
IMEnabled: strings.TrimSpace(row.IMTenantCode) != "" && strings.TrimSpace(row.IMUserID) != "",
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/accounts"
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/accounts"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
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) {
|
||||
return r.notices, nil
|
||||
}
|
||||
func (r fakeRepository) LatestPopupNotice(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) {
|
||||
if r.noticePage == nil {
|
||||
return &store.MemberNoticePage{Items: []store.MemberNotice{}}, nil
|
||||
@@ -193,3 +202,30 @@ func TestNoticesRejectsOversizedPage(t *testing.T) {
|
||||
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,
|
||||
level_id,parent_id,level,tree,invite_code,member_status,risk_status,auto_dispatch,
|
||||
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,
|
||||
levelID, parentID, teamLevel, tree, input.InviteCode)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
if strings.Contains(err.Error(), "uk_app_member_invite_code") {
|
||||
return nil, ErrInviteCodeExists
|
||||
}
|
||||
return nil, ErrAccountExists
|
||||
}
|
||||
return nil, err
|
||||
@@ -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) {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return 0, 1, "", nil
|
||||
return 0, 0, "", ErrReferralInvalid
|
||||
}
|
||||
var parent struct {
|
||||
ID int64 `db:"id"`
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package store
|
||||
|
||||
import "context"
|
||||
|
||||
type AttachmentCreation struct {
|
||||
MemberID int64
|
||||
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,'local',?,'image',?,'image',?,?,?, ?,?,1,UTC_TIMESTAMP(),UTC_TIMESTAMP())`,
|
||||
input.MemberID, 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 (
|
||||
ErrAccountExists = errors.New("account already exists")
|
||||
ErrInviteCodeExists = errors.New("invite code already exists")
|
||||
ErrAccountNotFound = errors.New("account not found")
|
||||
ErrAccountRestricted = errors.New("account restricted")
|
||||
ErrAttachmentInvalid = errors.New("attachment invalid")
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/config"
|
||||
"git.118im.com/im_team/olons_server/internal/config"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
|
||||
@@ -83,6 +83,9 @@ type CustomerService struct {
|
||||
OnlineStatus int `db:"online_status"`
|
||||
ServiceURL string `db:"service_url"`
|
||||
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) {
|
||||
@@ -161,6 +164,49 @@ func (s *Store) ActiveMemberNotices(ctx context.Context, tenantID, memberID int6
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (s *Store) LatestPopupNotice(ctx context.Context, tenantID, memberID int64) (*MemberNotice, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
var row MemberNotice
|
||||
err := s.db.GetContext(ctx, &row, `SELECT id,title,content,source_type,
|
||||
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(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
|
||||
AND start_at<=UTC_TIMESTAMP(3) AND end_at>UTC_TIMESTAMP(3)
|
||||
ORDER BY start_at DESC,id DESC LIMIT 1`, tenantID, 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")
|
||||
}
|
||||
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,?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3)
|
||||
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<=UTC_TIMESTAMP(3) AND n.end_at>UTC_TIMESTAMP(3)
|
||||
ON DUPLICATE KEY UPDATE read_at=VALUES(read_at)`, memberID, tenantID, noticeID, memberID)
|
||||
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) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
@@ -222,7 +268,8 @@ func (s *Store) ActiveCustomerServices(ctx context.Context, tenantID int64) ([]C
|
||||
}
|
||||
rows := make([]CustomerService, 0)
|
||||
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)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ type MemberOverview struct {
|
||||
Username string `db:"username"`
|
||||
Mobile string `db:"mobile"`
|
||||
InviteCode string `db:"invite_code"`
|
||||
HasTradePassword int `db:"has_trade_password"`
|
||||
MemberStatus int `db:"member_status"`
|
||||
MemberCategory int `db:"member_category"`
|
||||
CreditScore int `db:"credit_score"`
|
||||
@@ -49,7 +50,8 @@ func (s *Store) MemberOverview(ctx context.Context, tenantID, memberID int64) (*
|
||||
}
|
||||
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,
|
||||
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(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,
|
||||
@@ -92,7 +94,7 @@ func (s *Store) UpdateAvatar(ctx context.Context, tenantID, memberID, attachment
|
||||
|
||||
var avatar Avatar
|
||||
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)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAttachmentInvalid
|
||||
|
||||
@@ -56,6 +56,8 @@ type RechargePage struct {
|
||||
Total int64
|
||||
}
|
||||
|
||||
const rechargeStatusReview = 2
|
||||
|
||||
func (s *Store) RechargeChannels(ctx context.Context, tenantID int64) ([]RechargeChannel, error) {
|
||||
items := make([]RechargeChannel, 0)
|
||||
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 {
|
||||
var count int
|
||||
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
|
||||
}
|
||||
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
|
||||
(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)
|
||||
VALUES(?,?,?,?,?,0,?,?,1,1,?,?,?)`, input.TenantID, input.RechargeNo, input.MemberID,
|
||||
input.ChannelID, input.RequestAmount, input.Currency, vouchers, input.Remark, input.MemberID, input.MemberID)
|
||||
VALUES(?,?,?,?,?,0,?,?,1,?,?,?,?)`, input.TenantID, input.RechargeNo, input.MemberID,
|
||||
input.ChannelID, input.RequestAmount, input.Currency, vouchers, rechargeStatusReview, input.Remark, input.MemberID, input.MemberID)
|
||||
if err != nil {
|
||||
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,
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
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) MemberIMIdentity(ctx context.Context, tenantID, memberID int64) (*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=? LIMIT 1`, tenantID, memberID)
|
||||
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.MemberIMIdentity(ctx, identity.TenantID, identity.MemberID)
|
||||
}
|
||||
// A concurrent request may have completed the same deterministic mapping.
|
||||
existing, findErr := s.MemberIMIdentity(ctx, identity.TenantID, identity.MemberID)
|
||||
if findErr == nil {
|
||||
return existing, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
+426
-64
@@ -2,9 +2,14 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
cryptorand "crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
@@ -35,6 +40,7 @@ type TaskHall struct {
|
||||
type TaskEligibility struct {
|
||||
MemberLevelID int64 `db:"member_level_id"`
|
||||
MemberStatus int `db:"member_status"`
|
||||
AutoDispatch int `db:"auto_dispatch"`
|
||||
HallEligible bool `db:"hall_eligible"`
|
||||
AvailableAmount int64 `db:"available_amount"`
|
||||
WalletStatus int `db:"wallet_status"`
|
||||
@@ -48,6 +54,7 @@ type ClaimTaskInput struct {
|
||||
MemberID int64
|
||||
HallID int64
|
||||
MinimumAmount int64
|
||||
AutoDelay int
|
||||
DeadlineAt time.Time
|
||||
OrderNo string
|
||||
}
|
||||
@@ -104,12 +111,15 @@ type TaskStats struct {
|
||||
}
|
||||
|
||||
type TaskSubmissionInput struct {
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
OrderID int64
|
||||
SubmissionNo string
|
||||
EvidenceText string
|
||||
AttachmentIDs []int64
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
OrderID int64
|
||||
SubmissionNo string
|
||||
EvidenceText string
|
||||
AttachmentIDs []int64
|
||||
LedgerNo string
|
||||
IdempotencyKey string
|
||||
SettledAt time.Time
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
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,
|
||||
@@ -159,9 +169,10 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
|
||||
defer tx.Rollback()
|
||||
|
||||
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)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAccountNotFound
|
||||
@@ -194,7 +205,7 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
|
||||
if wallet.Status != 1 {
|
||||
return nil, ErrAccountRestricted
|
||||
}
|
||||
if wallet.AvailableAmount < input.MinimumAmount {
|
||||
if member.AutoDispatch == 1 && (wallet.AvailableAmount <= 0 || wallet.AvailableAmount < input.MinimumAmount) {
|
||||
return nil, ErrInsufficientBalance
|
||||
}
|
||||
var pending int64
|
||||
@@ -207,8 +218,12 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
|
||||
if pending > 0 {
|
||||
return nil, ErrPendingOrderExists
|
||||
}
|
||||
var dailyLimit int
|
||||
err = tx.GetContext(ctx, &dailyLimit, `SELECT COALESCE(v.daily_order_limit,0) FROM hg_app_member_level l
|
||||
var levelRule struct {
|
||||
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
|
||||
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) {
|
||||
@@ -217,44 +232,53 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dailyLimit > 0 {
|
||||
if levelRule.DailyLimit > 0 {
|
||||
var todayCount int
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
if todayCount >= dailyLimit {
|
||||
if todayCount >= levelRule.DailyLimit {
|
||||
return nil, ErrDailyLimitReached
|
||||
}
|
||||
}
|
||||
var task struct {
|
||||
ID int64 `db:"id"`
|
||||
Title string `db:"title"`
|
||||
CoverURL string `db:"cover_url"`
|
||||
CommissionAmount int64 `db:"commission_amount"`
|
||||
CommissionRate int `db:"commission_rate"`
|
||||
PerMemberLimit int `db:"per_member_limit"`
|
||||
}
|
||||
err = tx.GetContext(ctx, &task, `SELECT t.id,t.title,t.cover_url,t.commission_amount,t.commission_rate,t.per_member_limit
|
||||
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=?
|
||||
WHERE t.tenant_id=? AND t.hall_id=? AND t.publish_status=3 AND t.deleted_at IS NULL
|
||||
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
|
||||
AND od.order_type=2 AND od.created_at>=UTC_DATE() AND od.created_at<UTC_DATE()+INTERVAL 1 DAY)<t.daily_limit)
|
||||
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 errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNoTaskAvailable
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
dispatchMode, message := "manual", "等待后台匹配订单"
|
||||
matchAt := time.Now().UTC()
|
||||
deadlineAt := input.DeadlineAt.UTC()
|
||||
delaySeconds := 0
|
||||
if member.AutoDispatch == 1 {
|
||||
dispatchMode, message = "auto", "正在自动匹配订单"
|
||||
delayMin, delayMax := input.AutoDelay, input.AutoDelay
|
||||
var levelRules map[string]any
|
||||
if json.Unmarshal(levelRule.RuleJSON, &levelRules) == nil {
|
||||
if value, ok := jsonInt(levelRules["auto_order_delay_min_seconds"]); ok {
|
||||
delayMin = value
|
||||
}
|
||||
if value, ok := jsonInt(levelRules["auto_order_delay_max_seconds"]); ok {
|
||||
delayMax = value
|
||||
}
|
||||
}
|
||||
if delayMin < 0 {
|
||||
delayMin = 0
|
||||
}
|
||||
if delayMax < delayMin {
|
||||
delayMax = delayMin
|
||||
}
|
||||
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{
|
||||
"commission_amount": task.CommissionAmount,
|
||||
"commission_rate": task.CommissionRate,
|
||||
"deadline_at": input.DeadlineAt.UTC().Format(time.RFC3339),
|
||||
"member_level_id": member.LevelID,
|
||||
"source": "member_auto_claim",
|
||||
"dispatch_mode": dispatchMode,
|
||||
"hall_id": input.HallID,
|
||||
"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 {
|
||||
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
|
||||
(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,
|
||||
message,order_status,freeze_status,matched_at,created_by,updated_by)
|
||||
VALUES(?,?,?,?,0,?,2,1,0,0,?,'CNY',?,?,?,2,1,UTC_TIMESTAMP(3),0,0)`,
|
||||
input.TenantID, input.OrderNo, input.MemberID, task.ID, task.Title, task.CommissionAmount,
|
||||
member.LevelID, rules, "任务匹配成功,请在截止时间前完成")
|
||||
message,order_status,freeze_status,created_by,updated_by)
|
||||
VALUES(?,?,?,0,0,'',2,0,0,0,0,'CNY',?,?,?,1,1,0,0)`,
|
||||
input.TenantID, input.OrderNo, input.MemberID, member.LevelID, rules, message)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
return nil, ErrPendingOrderExists
|
||||
@@ -279,11 +302,209 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TaskOrder{ID: orderID, OrderNo: input.OrderNo, TaskID: task.ID, ProductName: task.Title,
|
||||
CoverURL: task.CoverURL, Quantity: 1, CommissionAmount: task.CommissionAmount, Currency: "CNY",
|
||||
MemberLevelID: member.LevelID, RuleSnapshot: rules, Message: "任务匹配成功,请在截止时间前完成",
|
||||
OrderStatus: orderStatusPending, FreezeStatus: 1, DeadlineAt: input.DeadlineAt.UTC().Format(time.RFC3339),
|
||||
MatchedAt: time.Now().UTC().Format(time.RFC3339), CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil
|
||||
return &TaskOrder{ID: orderID, OrderNo: input.OrderNo, Currency: "CNY",
|
||||
MemberLevelID: member.LevelID, RuleSnapshot: rules, Message: message,
|
||||
OrderStatus: orderStatusMatching, FreezeStatus: 1, DeadlineAt: deadlineAt.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) {
|
||||
@@ -364,13 +585,16 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var order struct {
|
||||
TaskID int64 `db:"task_id"`
|
||||
MemberLevelID int64 `db:"member_level_id"`
|
||||
CommissionAmount int64 `db:"commission_amount"`
|
||||
OrderStatus int `db:"order_status"`
|
||||
DeadlineAt string `db:"deadline_at"`
|
||||
OrderNo string `db:"order_no"`
|
||||
TaskID int64 `db:"task_id"`
|
||||
MemberLevelID int64 `db:"member_level_id"`
|
||||
TotalAmount int64 `db:"total_amount"`
|
||||
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
|
||||
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)
|
||||
@@ -383,8 +607,91 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
|
||||
if order.OrderStatus != orderStatusPending {
|
||||
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 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)
|
||||
WHERE tenant_id=? AND id=? AND order_status=2`, input.TenantID, input.OrderID); updateErr != nil {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
if input.SubmissionNo == "" {
|
||||
return nil, ErrOrderState
|
||||
}
|
||||
attachments, err := json.Marshal(input.AttachmentIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO hg_task_submission
|
||||
now := input.SettledAt.UTC()
|
||||
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,
|
||||
evidence_attachment_ids,submission_status,commission_amount,success_rate,allow_resubmit,created_by,updated_by)
|
||||
VALUES(?,?,?,?,?,?,?,?,1,?,0,2,0,0)`, input.TenantID, input.SubmissionNo, input.OrderID,
|
||||
order.TaskID, input.MemberID, order.MemberLevelID, input.EvidenceText, attachments, order.CommissionAmount)
|
||||
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(?,?,?,?,?,?,?,?,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 isDuplicate(err) {
|
||||
return nil, ErrOrderState
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE hg_trade_order SET order_status=3,message='已提交,等待审核',updated_at=UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id=? AND id=? AND member_id=? AND order_status=2`, input.TenantID, input.OrderID, input.MemberID)
|
||||
if affected, _ := submissionResult.RowsAffected(); affected != 1 {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -425,7 +772,22 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
|
||||
if err := tx.Commit(); err != nil {
|
||||
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 {
|
||||
@@ -433,7 +795,7 @@ func validateEvidenceAttachments(ctx context.Context, tx *sqlx.Tx, tenantID, mem
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return 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)
|
||||
}
|
||||
}
|
||||
+203
-83
@@ -10,39 +10,53 @@ import (
|
||||
)
|
||||
|
||||
type PaymentAccount struct {
|
||||
ID int64 `db:"id"`
|
||||
AccountType int `db:"account_type"`
|
||||
BankCode string `db:"bank_code"`
|
||||
BankName string `db:"bank_name"`
|
||||
HolderCipher string `db:"holder_cipher"`
|
||||
HolderMask string `db:"holder_mask"`
|
||||
MobileCipher string `db:"mobile_cipher"`
|
||||
MobileMask string `db:"mobile_mask"`
|
||||
AccountNoCipher string `db:"account_no_cipher"`
|
||||
AccountNoHash string `db:"account_no_hash"`
|
||||
AccountNoMask string `db:"account_no_mask"`
|
||||
QRAttachmentID int64 `db:"qr_attachment_id"`
|
||||
VerifyStatus int `db:"verify_status"`
|
||||
IsDefault int `db:"is_default"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
UpdatedAt string `db:"updated_at"`
|
||||
ID int64 `db:"id"`
|
||||
AccountType int `db:"account_type"`
|
||||
BankCode string `db:"bank_code"`
|
||||
BankName string `db:"bank_name"`
|
||||
HolderCipher string `db:"holder_cipher"`
|
||||
HolderMask string `db:"holder_mask"`
|
||||
IdentityNoCipher string `db:"identity_no_cipher"`
|
||||
IdentityNoHash string `db:"identity_no_hash"`
|
||||
IdentityNoMask string `db:"identity_no_mask"`
|
||||
MobileCipher string `db:"mobile_cipher"`
|
||||
MobileMask string `db:"mobile_mask"`
|
||||
AccountNoCipher string `db:"account_no_cipher"`
|
||||
AccountNoHash string `db:"account_no_hash"`
|
||||
AccountNoMask string `db:"account_no_mask"`
|
||||
DigitalRMBCodeCipher string `db:"digital_rmb_code_cipher"`
|
||||
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 {
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
ExpectedTradePasswordHash string
|
||||
AccountType int
|
||||
BankCode string
|
||||
BankName string
|
||||
HolderCipher string
|
||||
HolderMask string
|
||||
MobileCipher string
|
||||
MobileMask string
|
||||
AccountNoCipher string
|
||||
AccountNoHash string
|
||||
AccountNoMask string
|
||||
QRAttachmentID int64
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
AccountType int
|
||||
BankCode string
|
||||
BankName string
|
||||
HolderCipher string
|
||||
HolderMask string
|
||||
IdentityNoCipher string
|
||||
IdentityNoHash string
|
||||
IdentityNoMask string
|
||||
AccountNoCipher string
|
||||
AccountNoHash string
|
||||
AccountNoMask string
|
||||
DigitalRMBCodeCipher string
|
||||
DigitalRMBCodeHash string
|
||||
DigitalRMBCodeMask string
|
||||
WechatQRAttachmentID int64
|
||||
AlipayQRAttachmentID int64
|
||||
}
|
||||
|
||||
type WithdrawalChannel struct {
|
||||
@@ -67,30 +81,34 @@ type WithdrawalWallet struct {
|
||||
}
|
||||
|
||||
type WithdrawalCreation struct {
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
ExpectedTradePasswordHash string
|
||||
WithdrawalNo string
|
||||
PaymentAccountID int64
|
||||
ExpectedAccountType int
|
||||
ExpectedBankCode string
|
||||
ExpectedBankName string
|
||||
ExpectedHolderCipher string
|
||||
ExpectedMobileCipher string
|
||||
ExpectedAccountNoCipher string
|
||||
ExpectedAccountNoHash string
|
||||
ExpectedQRAttachmentID int64
|
||||
Channel WithdrawalChannel
|
||||
RequestAmount int64
|
||||
FeeAmount int64
|
||||
ActualAmount int64
|
||||
Currency string
|
||||
AccountSnapshotCipher string
|
||||
AccountMask string
|
||||
BankName string
|
||||
HolderMask string
|
||||
AvailableLedgerNo string
|
||||
FrozenLedgerNo string
|
||||
TenantID int64
|
||||
MemberID int64
|
||||
ExpectedTradePasswordHash string
|
||||
WithdrawalNo string
|
||||
PaymentAccountID int64
|
||||
ExpectedAccountType int
|
||||
ExpectedBankCode string
|
||||
ExpectedBankName string
|
||||
ExpectedHolderCipher string
|
||||
ExpectedIdentityNoCipher string
|
||||
ExpectedIdentityNoHash string
|
||||
ExpectedAccountNoCipher string
|
||||
ExpectedAccountNoHash string
|
||||
ExpectedDigitalRMBCodeCipher string
|
||||
ExpectedDigitalRMBCodeHash string
|
||||
ExpectedWechatQRAttachmentID int64
|
||||
ExpectedAlipayQRAttachmentID int64
|
||||
Channel WithdrawalChannel
|
||||
RequestAmount int64
|
||||
FeeAmount int64
|
||||
ActualAmount int64
|
||||
Currency string
|
||||
AccountSnapshotCipher string
|
||||
AccountMask string
|
||||
BankName string
|
||||
HolderMask string
|
||||
AvailableLedgerNo string
|
||||
FrozenLedgerNo string
|
||||
}
|
||||
|
||||
type WithdrawalOrder struct {
|
||||
@@ -126,8 +144,11 @@ type WithdrawalPage struct {
|
||||
|
||||
func (s *Store) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
|
||||
items := make([]PaymentAccount, 0)
|
||||
err := s.db.SelectContext(ctx, &items, `SELECT id,account_type,bank_code,bank_name,holder_mask,mobile_mask,
|
||||
account_no_mask,qr_attachment_id,verify_status,is_default,
|
||||
err := s.db.SelectContext(ctx, &items, `SELECT id,account_type,bank_code,bank_name,holder_mask,
|
||||
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(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
|
||||
@@ -135,6 +156,33 @@ func (s *Store) PaymentAccounts(ctx context.Context, tenantID, memberID int64) (
|
||||
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) {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -143,25 +191,22 @@ func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCr
|
||||
defer tx.Rollback()
|
||||
var memberID int64
|
||||
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`,
|
||||
input.TenantID, input.MemberID, input.ExpectedTradePasswordHash)
|
||||
AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrConcurrentUpdate
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.QRAttachmentID > 0 {
|
||||
var attachmentCount int
|
||||
err = tx.GetContext(ctx, &attachmentCount, `SELECT COUNT(*) FROM hg_sys_attachment
|
||||
WHERE tenant_id=? AND id=? AND app_id='workbench' AND member_id=? AND status=1 AND mime_type LIKE 'image/%'`,
|
||||
input.TenantID, input.QRAttachmentID, input.MemberID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if attachmentCount != 1 {
|
||||
return nil, ErrAttachmentInvalid
|
||||
}
|
||||
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
|
||||
}
|
||||
var accountCount int
|
||||
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
|
||||
}
|
||||
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,
|
||||
mobile_mask,account_no_cipher,account_no_hash,account_no_mask,qr_attachment_id,verify_status,
|
||||
(tenant_id,member_id,account_type,bank_code,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,
|
||||
is_default,created_by,updated_by)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?)`, input.TenantID, input.MemberID, input.AccountType,
|
||||
input.BankCode, input.BankName, input.HolderCipher, input.HolderMask, nullableString(input.MobileCipher),
|
||||
input.MobileMask, input.AccountNoCipher, input.AccountNoHash, input.AccountNoMask, input.QRAttachmentID,
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,2,?,?,?)`, input.TenantID, input.MemberID, input.AccountType,
|
||||
input.BankCode, 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,
|
||||
isDefault, input.MemberID, input.MemberID)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
@@ -194,9 +244,64 @@ func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCr
|
||||
return nil, err
|
||||
}
|
||||
return &PaymentAccount{ID: id, AccountType: input.AccountType, BankCode: input.BankCode,
|
||||
BankName: input.BankName, HolderMask: input.HolderMask, MobileMask: input.MobileMask,
|
||||
AccountNoMask: input.AccountNoMask, QRAttachmentID: input.QRAttachmentID,
|
||||
VerifyStatus: 1, IsDefault: isDefault}, nil
|
||||
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 (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 {
|
||||
@@ -209,8 +314,13 @@ func nullableString(value string) any {
|
||||
func (s *Store) PaymentAccountForWithdrawal(ctx context.Context, tenantID, memberID, accountID int64) (*PaymentAccount, error) {
|
||||
var row PaymentAccount
|
||||
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,
|
||||
account_no_mask,qr_attachment_id,verify_status,is_default,
|
||||
COALESCE(identity_no_cipher,'') AS identity_no_cipher,COALESCE(identity_no_hash,'') AS identity_no_hash,
|
||||
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(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`,
|
||||
@@ -289,8 +399,14 @@ func (s *Store) CreateWithdrawal(ctx context.Context, input WithdrawalCreation)
|
||||
|
||||
var account PaymentAccount
|
||||
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,
|
||||
account_no_mask,qr_attachment_id,verify_status,is_default,'' AS created_at,'' AS updated_at
|
||||
COALESCE(identity_no_cipher,'') AS identity_no_cipher,COALESCE(identity_no_hash,'') AS identity_no_hash,
|
||||
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=?
|
||||
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) {
|
||||
@@ -301,8 +417,12 @@ func (s *Store) CreateWithdrawal(ctx context.Context, input WithdrawalCreation)
|
||||
}
|
||||
if account.AccountType != input.ExpectedAccountType || account.BankCode != input.ExpectedBankCode ||
|
||||
account.BankName != input.ExpectedBankName || account.HolderCipher != input.ExpectedHolderCipher ||
|
||||
account.MobileCipher != input.ExpectedMobileCipher || account.AccountNoCipher != input.ExpectedAccountNoCipher ||
|
||||
account.AccountNoHash != input.ExpectedAccountNoHash || account.QRAttachmentID != input.ExpectedQRAttachmentID {
|
||||
account.IdentityNoCipher != input.ExpectedIdentityNoCipher || account.IdentityNoHash != input.ExpectedIdentityNoHash ||
|
||||
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
|
||||
}
|
||||
var channel WithdrawalChannel
|
||||
|
||||
@@ -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,309 @@
|
||||
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)
|
||||
MemberIMIdentity(context.Context, int64, int64) (*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)
|
||||
CreateFriendRequest(context.Context, string, string, string) (*imclient.FriendRequest, 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"`
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query customer service relationship: %w", err)
|
||||
}
|
||||
relationship := strings.TrimSpace(profile.Relationship)
|
||||
switch relationship {
|
||||
case "friend":
|
||||
case "none", "":
|
||||
key := fmt.Sprintf("customer-service:%d:%d:%d", tenantID, memberID, serviceID)
|
||||
request, requestErr := s.im.CreateFriendRequest(ctx, imSession.Token, agent.IMUserID, key)
|
||||
if requestErr != nil && !imclient.IsConflict(requestErr) {
|
||||
return nil, fmt.Errorf("create customer service friend request: %w", requestErr)
|
||||
}
|
||||
if requestErr != nil {
|
||||
profile, err = s.im.UserProfile(ctx, imSession.Token, agent.IMUserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refresh customer service relationship: %w", err)
|
||||
}
|
||||
relationship = strings.TrimSpace(profile.Relationship)
|
||||
} else {
|
||||
relationship = strings.TrimSpace(request.Status)
|
||||
}
|
||||
if relationship != "accepted" && relationship != "friend" {
|
||||
return nil, ErrRelationPending
|
||||
}
|
||||
case "pending_inbound", "pending_outbound", "pending":
|
||||
return nil, ErrRelationPending
|
||||
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: cfg.HTTPAPIURL, WebSocketURL: cfg.WebSocketURL,
|
||||
FileURL: cfg.FileURL, 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.MemberIMIdentity(ctx, tenantID, memberID)
|
||||
if err == nil {
|
||||
if !strings.EqualFold(identity.IMTenantCode, imTenantCode) || 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
|
||||
}
|
||||
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,125 @@
|
||||
package support
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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
|
||||
}
|
||||
|
||||
func (r *repositoryStub) CustomerServiceByID(context.Context, int64, int64) (*store.CustomerService, error) {
|
||||
return r.agent, nil
|
||||
}
|
||||
func (r *repositoryStub) MemberIMIdentity(context.Context, int64, int64) (*store.MemberIMIdentity, error) {
|
||||
return r.identity, nil
|
||||
}
|
||||
func (r *repositoryStub) MemberIMProfile(context.Context, int64, int64) (*store.MemberIMProfile, error) {
|
||||
return nil, store.ErrResourceNotFound
|
||||
}
|
||||
func (r *repositoryStub) SaveMemberIMIdentity(context.Context, *store.MemberIMIdentity) (*store.MemberIMIdentity, error) {
|
||||
return r.identity, nil
|
||||
}
|
||||
|
||||
type imStub struct {
|
||||
friendRequests int
|
||||
loginAttempts int
|
||||
resetPasswords int
|
||||
loginError error
|
||||
}
|
||||
|
||||
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, string, string, string) (*imclient.CreatedUser, error) {
|
||||
return nil, 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) {
|
||||
return &imclient.UserProfile{UserID: "usr_agent", Relationship: "none"}, nil
|
||||
}
|
||||
func (i *imStub) CreateFriendRequest(context.Context, string, string, string) (*imclient.FriendRequest, error) {
|
||||
i.friendRequests++
|
||||
return &imclient.FriendRequest{RequestID: "frq_1", Status: "accepted"}, nil
|
||||
}
|
||||
func (i *imStub) EnsureP2P(context.Context, string, string, string) (*imclient.Conversation, error) {
|
||||
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.friendRequests != 1 {
|
||||
t.Fatalf("friend requests = %d, want 1", im.friendRequests)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,13 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -26,6 +27,7 @@ type Repository interface {
|
||||
TaskHalls(context.Context, int64, int64) ([]store.TaskHall, error)
|
||||
TaskEligibility(context.Context, int64, int64, int64) (*store.TaskEligibility, 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)
|
||||
TaskOrder(context.Context, int64, int64, int64) (*store.TaskOrder, error)
|
||||
TaskStats(context.Context, int64, int64) (*store.TaskStats, error)
|
||||
@@ -75,6 +77,9 @@ type Order struct {
|
||||
Status string `json:"status"`
|
||||
StatusText string `json:"status_text"`
|
||||
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"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
@@ -150,6 +155,9 @@ type SubmitResult struct {
|
||||
type taskPolicy struct {
|
||||
Enabled bool
|
||||
MinimumAmount int64
|
||||
AutoDelay int
|
||||
AutoMinAmount int64
|
||||
AutoMaxAmount int64
|
||||
Start string
|
||||
End string
|
||||
Deadline time.Duration
|
||||
@@ -198,7 +206,7 @@ func (s *Service) Eligibility(ctx context.Context, tenantID, memberID, hallID in
|
||||
result.reject("LEVEL_NOT_ELIGIBLE", "当前等级不能进入该任务大厅")
|
||||
case row.WalletStatus != 1:
|
||||
result.reject("ACCOUNT_RESTRICTED", "账户状态受限")
|
||||
case row.AvailableAmount < policy.MinimumAmount:
|
||||
case row.AutoDispatch == 1 && (row.AvailableAmount <= 0 || row.AvailableAmount < policy.MinimumAmount):
|
||||
result.reject("INSUFFICIENT_BALANCE", "可用余额不足")
|
||||
case row.DailyLimit > 0 && row.TodayCount >= row.DailyLimit:
|
||||
result.reject("DAILY_LIMIT_REACHED", "已达当日领取上限")
|
||||
@@ -230,14 +238,39 @@ func (s *Service) Claim(ctx context.Context, tenantID, memberID, hallID int64) (
|
||||
}
|
||||
now := s.now().UTC()
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
result := orderFromStore(*row, false)
|
||||
s.scheduleAutoMatch(tenantID, memberID, row.ID, row.RuleSnapshot, policy)
|
||||
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) {
|
||||
if page == 0 {
|
||||
page = 1
|
||||
@@ -274,6 +307,24 @@ func (s *Service) Order(ctx context.Context, tenantID, memberID, orderID int64)
|
||||
if err != nil {
|
||||
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})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -300,17 +351,18 @@ func (s *Service) Submit(ctx context.Context, tenantID, memberID, orderID int64,
|
||||
}
|
||||
input.EvidenceText = strings.TrimSpace(input.EvidenceText)
|
||||
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
|
||||
}
|
||||
now := s.now().UTC()
|
||||
row, err := s.repository.SubmitTask(ctx, store.TaskSubmissionInput{TenantID: tenantID, MemberID: memberID,
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -331,6 +383,9 @@ func (s *Service) policy(ctx context.Context, tenantID int64) (taskPolicy, error
|
||||
end := clockValue(findValue(config, "task_business_end_at", "business_end_at", "taskBusinessEndAt"), "23:59:59")
|
||||
return taskPolicy{Enabled: boolValue(findValue(config, "task_enabled", "taskEnabled", "TASK")),
|
||||
MinimumAmount: max64(0, int64Value(findValue(config, "task_minimum_amount", "minimum_balance_amount", "taskMinimumAmount"))),
|
||||
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"))),
|
||||
Start: start, End: end, Deadline: time.Duration(minutes) * time.Minute}, nil
|
||||
}
|
||||
|
||||
@@ -341,6 +396,12 @@ func orderFromStore(row store.TaskOrder, detail bool) Order {
|
||||
UnitPriceAmount: row.UnitPriceAmount, TotalAmount: row.TotalAmount, CommissionAmount: row.CommissionAmount,
|
||||
Currency: row.Currency, Status: status, StatusText: orderStatusText(row.OrderStatus), Message: row.Message,
|
||||
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 {
|
||||
return result
|
||||
}
|
||||
@@ -382,7 +443,11 @@ func orderTimeline(row store.TaskOrder) []TimelineEntry {
|
||||
func allowedActions(row store.TaskOrder) []string {
|
||||
switch row.OrderStatus {
|
||||
case 2:
|
||||
return []string{"submit", "contact_support"}
|
||||
var snapshot map[string]any
|
||||
if json.Unmarshal(row.RuleSnapshot, &snapshot) == nil && snapshot["dispatch_mode"] == "manual" {
|
||||
return []string{"submit", "contact_support"}
|
||||
}
|
||||
return []string{"contact_support"}
|
||||
case 3, 5, 6, 8:
|
||||
return []string{"contact_support"}
|
||||
default:
|
||||
|
||||
@@ -4,10 +4,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.118im.com/im_team/im_workbench/internal/store"
|
||||
"git.118im.com/im_team/olons_server/internal/store"
|
||||
)
|
||||
|
||||
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) {
|
||||
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
|
||||
}
|
||||
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) {
|
||||
return &store.TaskOrderPage{Items: []store.TaskOrder{*f.order}, Total: 1}, nil
|
||||
}
|
||||
@@ -43,7 +55,7 @@ func (f *fakeRepository) TaskStats(context.Context, int64, int64) (*store.TaskSt
|
||||
}
|
||||
func (f *fakeRepository) SubmitTask(_ context.Context, input store.TaskSubmissionInput) (*store.TaskSubmissionResult, error) {
|
||||
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
|
||||
}
|
||||
func (f *fakeRepository) OrderLedgers(context.Context, int64, int64, []string) ([]map[string]any, error) {
|
||||
@@ -52,7 +64,7 @@ func (f *fakeRepository) OrderLedgers(context.Context, int64, int64, []string) (
|
||||
|
||||
func TestEligibilityAppliesPublishedPolicy(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"}`),
|
||||
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: 49999, WalletStatus: 1, DailyLimit: 5, TodayCount: 2}}
|
||||
service := New(repository)
|
||||
service.now = func() time.Time { return time.Date(2026, 10, 9, 10, 0, 0, 0, shanghai) }
|
||||
|
||||
@@ -65,6 +77,36 @@ func TestEligibilityAppliesPublishedPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEligibilityAllowsManualDispatchBelowMinimumBalance(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"}`),
|
||||
eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 2, HallEligible: true, AvailableAmount: 0, WalletStatus: 1, DailyLimit: 5}}
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Eligible {
|
||||
t.Fatalf("manual dispatch should ignore the automatic minimum balance gate: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEligibilityRejectsAutoDispatchWithZeroBalanceWhenMinimumIsZero(t *testing.T) {
|
||||
repository := &fakeRepository{config: json.RawMessage(`{"features":{"task_enabled":true},"task_minimum_amount":0,"task_business_start_at":"09:00","task_business_end_at":"22:00"}`),
|
||||
eligibility: &store.TaskEligibility{MemberStatus: 1, AutoDispatch: 1, HallEligible: true, AvailableAmount: 0, WalletStatus: 1, DailyLimit: 5}}
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Eligible || result.Reason != "INSUFFICIENT_BALANCE" {
|
||||
t.Fatalf("zero balance must not allow automatic claiming: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimFailsClosedWithoutTaskSwitch(t *testing.T) {
|
||||
repository := &fakeRepository{config: json.RawMessage(`{}`)}
|
||||
service := New(repository)
|
||||
@@ -75,7 +117,7 @@ func TestClaimFailsClosedWithoutTaskSwitch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimBuildsPendingOrder(t *testing.T) {
|
||||
func TestClaimBuildsMatchingOrder(t *testing.T) {
|
||||
repository := &fakeRepository{config: json.RawMessage(`{"task_enabled":true,"task_deadline_minutes":20}`)}
|
||||
service := New(repository)
|
||||
now := time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)
|
||||
@@ -85,29 +127,34 @@ func TestClaimBuildsPendingOrder(t *testing.T) {
|
||||
if err != nil {
|
||||
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)) {
|
||||
t.Fatalf("unexpected claim: result=%#v input=%#v", result, repository.claimed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitValidatesAndNormalizesEvidence(t *testing.T) {
|
||||
func TestSubmitAutomaticallySettlesWithoutEvidence(t *testing.T) {
|
||||
repository := &fakeRepository{}
|
||||
service := New(repository)
|
||||
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) {
|
||||
t.Fatalf("expected evidence error, got %v", err)
|
||||
}
|
||||
result, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{EvidenceText: " 已完成 ", EvidenceAttachmentIDs: []int64{3}})
|
||||
result, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{})
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
service := New(&fakeRepository{})
|
||||
_, err := service.Orders(context.Background(), 0, 10, 1, 20, "not-a-status")
|
||||
|
||||
@@ -19,7 +19,8 @@ class DeploymentContractTest(unittest.TestCase):
|
||||
dockerfile = (ROOT / "Dockerfile").read_text()
|
||||
|
||||
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 /app/web/dist", dockerfile)
|
||||
self.assertIn("EXPOSE 6061", dockerfile)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"@vicons/ionicons5": "^0.12.0",
|
||||
"axios": "^1.7.7",
|
||||
"naive-ui": "^2.44.1",
|
||||
"tinode-sdk": "^0.25.4",
|
||||
"vue": "^3.4.38",
|
||||
"vue-router": "^4.4.3"
|
||||
},
|
||||
|
||||
Generated
+17
@@ -17,6 +17,9 @@ importers:
|
||||
naive-ui:
|
||||
specifier: ^2.44.1
|
||||
version: 2.45.3(vue@3.4.38(typescript@5.5.4))
|
||||
tinode-sdk:
|
||||
specifier: ^0.25.4
|
||||
version: 0.25.4
|
||||
vue:
|
||||
specifier: ^3.4.38
|
||||
version: 3.4.38(typescript@5.5.4)
|
||||
@@ -258,46 +261,55 @@ packages:
|
||||
resolution: {integrity: sha512-ztRJJMiE8nnU1YFcdbd9BcH6bGWG1z+jP+IPW2oDUAPxPjo9dverIOyXz76m6IPA6udEL12reYeLojzW2cYL7w==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.21.2':
|
||||
resolution: {integrity: sha512-flOcGHDZajGKYpLV0JNc0VFH361M7rnV1ee+NTeC/BQQ1/0pllYcFmxpagltANYt8FYf9+kL6RSk80Ziwyhr7w==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.21.2':
|
||||
resolution: {integrity: sha512-69CF19Kp3TdMopyteO/LJbWufOzqqXzkrv4L2sP8kfMaAQ6iwky7NoXTp7bD6/irKgknDKM0P9E/1l5XxVQAhw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.21.2':
|
||||
resolution: {integrity: sha512-48pD/fJkTiHAZTnZwR0VzHrao70/4MlzJrq0ZsILjLW/Ab/1XlVUStYyGt7tdyIiVSlGZbnliqmult/QGA2O2w==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.21.2':
|
||||
resolution: {integrity: sha512-cZdyuInj0ofc7mAQpKcPR2a2iu4YM4FQfuUzCVA2u4HI95lCwzjoPtdWjdpDKyHxI0UO82bLDoOaLfpZ/wviyQ==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.21.2':
|
||||
resolution: {integrity: sha512-RL56JMT6NwQ0lXIQmMIWr1SW28z4E4pOhRRNqwWZeXpRlykRIlEpSWdsgNWJbYBEWD84eocjSGDu/XxbYeCmwg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.21.2':
|
||||
resolution: {integrity: sha512-PMxkrWS9z38bCr3rWvDFVGD6sFeZJw4iQlhrup7ReGmfn7Oukrr/zweLhYX6v2/8J6Cep9IEA/SmjXjCmSbrMQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.21.2':
|
||||
resolution: {integrity: sha512-B90tYAUoLhU22olrafY3JQCFLnT3NglazdwkHyxNDYF/zAxJt5fJUB/yBoWFoIQ7SQj+KLe3iL4BhOMa9fzgpw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.21.2':
|
||||
resolution: {integrity: sha512-7twFizNXudESmC9oneLGIUmoHiiLppz/Xs5uJQ4ShvE6234K0VB1/aJYU3f/4g7PhssLGKBVCC37uRkkOi8wjg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.21.2':
|
||||
resolution: {integrity: sha512-9rRero0E7qTeYf6+rFh3AErTNU1VCQg2mn7CQcI44vNUWM9Ze7MSRS/9RFuSsox+vstRt97+x3sOhEey024FRQ==}
|
||||
@@ -574,6 +586,9 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
tinode-sdk@0.25.4:
|
||||
resolution: {integrity: sha512-EFWWR3JDFH1oognJT3K8vH6l7TmcOqblvqx3FwiRY1RAsSUypxOzGfWMZZo0nfc7EmjK3SogRgqNWH6NxLS5+w==}
|
||||
|
||||
to-fast-properties@2.0.0:
|
||||
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -1151,6 +1166,8 @@ snapshots:
|
||||
source-map-support: 0.5.21
|
||||
optional: true
|
||||
|
||||
tinode-sdk@0.25.4: {}
|
||||
|
||||
to-fast-properties@2.0.0: {}
|
||||
|
||||
treemate@0.3.11: {}
|
||||
|
||||
@@ -2,12 +2,14 @@ import axios, { AxiosError, AxiosRequestConfig } from 'axios';
|
||||
|
||||
const TOKEN_KEY = 'olons_web_session';
|
||||
const DEVICE_KEY = 'olons_web_device';
|
||||
const LOGIN_NOTICE_PENDING_KEY = 'olons_login_notice_pending';
|
||||
|
||||
const reasonMessages: Record<string, string> = {
|
||||
AUTH_REQUIRED: '登录已失效,请重新登录',
|
||||
FORBIDDEN: '当前账户无权执行此操作',
|
||||
INVALID_CREDENTIALS: '手机号或密码错误',
|
||||
ACCOUNT_ALREADY_REGISTERED: '该账户或手机号已经注册',
|
||||
REFERRAL_CODE_INVALID: '邀请码无效,请检查后重新输入',
|
||||
RATE_LIMITED: '操作过于频繁,请稍后再试',
|
||||
TASK_DISABLED: '领取功能暂未开放',
|
||||
OUTSIDE_BUSINESS_HOURS: '当前不在营业时间内',
|
||||
@@ -19,6 +21,9 @@ const reasonMessages: Record<string, string> = {
|
||||
ORDER_STATE_INVALID: '当前任务状态不允许此操作',
|
||||
TRADE_PASSWORD_NOT_SET: '请先设置支付密码',
|
||||
TRADE_PASSWORD_INVALID: '支付密码不正确',
|
||||
PAYMENT_ACCOUNT_FIELDS_INVALID: '绑卡信息填写不完整或格式不正确',
|
||||
QR_ATTACHMENT_INVALID: '二维码图片无效,请重新上传',
|
||||
IMAGE_INVALID: '图片格式或大小不符合要求',
|
||||
PAYMENT_ACCOUNT_INVALID: '收款账户未核验或不可用',
|
||||
WITHDRAWAL_DISABLED: '取出功能暂未开放',
|
||||
WITHDRAWAL_AMOUNT_INVALID: '取出金额不符合当前规则',
|
||||
@@ -28,6 +33,11 @@ const reasonMessages: Record<string, string> = {
|
||||
RECHARGE_CHANNEL_UNAVAILABLE: '当前没有可用的储入通道',
|
||||
RECHARGE_VOUCHER_INVALID: '储入凭证无效',
|
||||
SERVICE_UNAVAILABLE: '服务暂不可用,请稍后重试',
|
||||
CUSTOMER_SERVICE_NOT_FOUND: '客服已下线或不存在',
|
||||
CUSTOMER_SERVICE_NOT_BOUND: '客服账号尚未配置',
|
||||
IM_RELATION_PENDING: '好友申请已发送,请等待客服确认',
|
||||
IM_RELATION_NOT_ALLOWED: '当前无法与该客服建立会话',
|
||||
IM_SERVICE_UNAVAILABLE: '客服系统暂不可用,请稍后重试',
|
||||
INTERNAL: '服务异常,请稍后重试',
|
||||
};
|
||||
|
||||
@@ -130,18 +140,32 @@ function getDeviceID() {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function deviceID() {
|
||||
return getDeviceID();
|
||||
}
|
||||
|
||||
export function saveSession(token: string) {
|
||||
sessionStorage.setItem(TOKEN_KEY, token);
|
||||
sessionStorage.setItem(LOGIN_NOTICE_PENDING_KEY, '1');
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
sessionStorage.removeItem(LOGIN_NOTICE_PENDING_KEY);
|
||||
}
|
||||
|
||||
export function hasSession() {
|
||||
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() {
|
||||
return crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ import processSimple from './assets/process-simple.png';
|
||||
import processMaterial from './assets/process-material.png';
|
||||
import processTime from './assets/process-time.png';
|
||||
import processReturn from './assets/process-return.png';
|
||||
import chatVoice from './assets/chat-voice.png';
|
||||
import chatMore from './assets/chat-more.png';
|
||||
import companyPlatform from './assets/company-platform.png';
|
||||
import companyRules from './assets/company-rules.png';
|
||||
@@ -108,7 +107,6 @@ export const mobileAssets = {
|
||||
processMaterial,
|
||||
processTime,
|
||||
processReturn,
|
||||
chatVoice,
|
||||
chatMore,
|
||||
companyPlatform,
|
||||
companyRules,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 645 KiB |
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="announcement-fade">
|
||||
<div v-if="show" class="m-announcement-mask" role="presentation">
|
||||
<section
|
||||
class="m-announcement-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="announcement-title"
|
||||
aria-describedby="announcement-content"
|
||||
>
|
||||
<h2 id="announcement-title" class="m-announcement-sr-title">
|
||||
{{ notice?.title || '公告通知' }}
|
||||
</h2>
|
||||
<img class="m-announcement-board" :src="announcementBoard" alt="" />
|
||||
<div class="m-announcement-body">
|
||||
<div id="announcement-content" class="m-announcement-content">
|
||||
<div v-if="notice?.body_html" v-html="notice.body_html"></div>
|
||||
<template v-else>{{ notice?.content }}</template>
|
||||
</div>
|
||||
<button type="button" @click="$emit('read')">我已阅读</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, watch } from 'vue';
|
||||
import announcementBoard from '../assets/announcement-popup@2x.png';
|
||||
|
||||
export interface AnnouncementNotice {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
body_html?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean;
|
||||
notice?: AnnouncementNotice | null;
|
||||
}>();
|
||||
|
||||
defineEmits<{ read: [] }>();
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
document.body.classList.toggle('m-announcement-open', show);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => document.body.classList.remove('m-announcement-open'));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.m-announcement-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px 16px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
}
|
||||
|
||||
.m-announcement-dialog {
|
||||
position: relative;
|
||||
width: min(312px, calc(100vw - 32px), calc((100vh - 32px) * 2 / 3));
|
||||
aspect-ratio: 2 / 3;
|
||||
}
|
||||
|
||||
.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-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;
|
||||
}
|
||||
|
||||
.announcement-fade-enter-active,
|
||||
.announcement-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.announcement-fade-enter-from,
|
||||
.announcement-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.m-announcement-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,254 @@
|
||||
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 class CustomerServiceChat {
|
||||
private tinode: any;
|
||||
private topic: any;
|
||||
private currentUserID: string;
|
||||
private onMessages: (messages: ChatMessage[]) => void;
|
||||
private session: CustomerServiceSession;
|
||||
|
||||
constructor(session: CustomerServiceSession, onMessages: (messages: ChatMessage[]) => void) {
|
||||
const endpoint = new URL(session.websocket_url);
|
||||
this.session = session;
|
||||
this.currentUserID = session.current_user_id;
|
||||
this.onMessages = onMessages;
|
||||
this.tinode = new Tinode({
|
||||
appName: 'Olons Web',
|
||||
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();
|
||||
}
|
||||
|
||||
async connect(token: string) {
|
||||
await this.tinode.connect();
|
||||
const login = await this.tinode.loginToken(token);
|
||||
if (login?.code >= 300) throw new Error('客服会话登录失败');
|
||||
const query = this.topic.startMetaQuery().withData(undefined, undefined, 50).build();
|
||||
const subscribed = await this.topic.subscribe(query);
|
||||
if (subscribed?.code >= 300) throw new Error('客服会话订阅失败');
|
||||
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() {
|
||||
try {
|
||||
this.topic?.leave?.(false);
|
||||
} finally {
|
||||
this.tinode?.disconnect?.();
|
||||
}
|
||||
}
|
||||
|
||||
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.session.file_url || '/v0/file', this.session.api_base_url);
|
||||
uploadURL.pathname = `${uploadURL.pathname.replace(/\/$/, '').replace(/\/u$/, '')}/u/`;
|
||||
uploadURL.searchParams.set('apikey', this.session.api_key);
|
||||
uploadURL.searchParams.set('auth', 'token');
|
||||
uploadURL.searchParams.set('secret', this.session.token);
|
||||
const form = new FormData();
|
||||
form.set('id', clientMID());
|
||||
form.set('file', file, file.name);
|
||||
onProgress?.(0.1);
|
||||
let response: Response;
|
||||
try {
|
||||
// Do not attach XMLHttpRequest upload progress listeners here. For a
|
||||
// cross-origin IM endpoint they trigger a CORS preflight which the file
|
||||
// service does not expose, while multipart/form-data itself is accepted.
|
||||
response = await fetch(uploadURL.toString(), { method: 'POST', 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.session.api_base_url);
|
||||
const resolved = new URL(ref, apiBase);
|
||||
// The SDK signs relative file URLs. Also tolerate historical absolute IM
|
||||
// references by converting same-origin URLs back to a signable path.
|
||||
const signable = resolved.origin === apiBase.origin
|
||||
? `${resolved.pathname}${resolved.search}`
|
||||
: ref;
|
||||
const authorized = this.tinode.authorizeURL(signable);
|
||||
return new URL(authorized, this.session.api_base_url).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';
|
||||
return {
|
||||
text: '',
|
||||
type,
|
||||
media_url: 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)}`;
|
||||
}
|
||||
+646
-24
@@ -375,12 +375,45 @@ body {
|
||||
background: var(--m-bg);
|
||||
}
|
||||
.m-home-hero {
|
||||
position: relative;
|
||||
min-height: 305px;
|
||||
padding: calc(34px + env(safe-area-inset-top)) 30px 26px;
|
||||
color: #fff;
|
||||
background-color: #1b1711;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
transition: background-image 0.35s ease;
|
||||
}
|
||||
.m-home-banner-link {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.m-home-hero > :not(.m-home-banner-link) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.m-home-banner-dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin-top: 13px;
|
||||
}
|
||||
.m-home-banner-dots button {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
transition: width 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
.m-home-banner-dots button.active {
|
||||
width: 20px;
|
||||
background: #fff;
|
||||
}
|
||||
.m-search {
|
||||
height: 48px;
|
||||
@@ -1008,6 +1041,108 @@ body {
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.m-order-submit-panel {
|
||||
padding: 17px;
|
||||
border-radius: 8px;
|
||||
background: #fff8ea;
|
||||
box-shadow: 0 4px 18px rgba(73, 54, 31, 0.07);
|
||||
}
|
||||
.m-order-submit-panel strong {
|
||||
color: #6f3f12;
|
||||
font-size: 17px;
|
||||
}
|
||||
.m-order-submit-panel p {
|
||||
margin: 7px 0 15px;
|
||||
color: #8b765e;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.m-order-submit-panel > button,
|
||||
.m-order-submit-actions button:last-child {
|
||||
border: 0;
|
||||
color: #fff;
|
||||
background: linear-gradient(90deg, #ffa32b, #f15b20);
|
||||
}
|
||||
.m-order-submit-panel > button {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
border-radius: 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.m-order-recharge-link {
|
||||
min-height: 46px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 24px;
|
||||
color: #fff;
|
||||
background: linear-gradient(90deg, #ffa32b, #f15b20);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.m-order-submit-panel button:disabled,
|
||||
.m-order-submit-actions button:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.m-order-submit-panel > button:disabled {
|
||||
color: #999;
|
||||
background: #e9e9e9;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.m-order-submit-confirm {
|
||||
width: min(90vw, 430px);
|
||||
overflow: hidden;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
box-shadow: 0 16px 50px rgba(70, 40, 10, 0.2);
|
||||
}
|
||||
.m-order-submit-confirm h2 {
|
||||
margin: 0;
|
||||
padding: 24px 22px 8px;
|
||||
text-align: center;
|
||||
font-size: 21px;
|
||||
}
|
||||
.m-order-submit-confirm > p {
|
||||
margin: 0;
|
||||
padding: 0 25px 18px;
|
||||
color: #8a8a8a;
|
||||
text-align: center;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.m-order-submit-confirm dl {
|
||||
margin: 0 20px 22px;
|
||||
padding: 13px 16px;
|
||||
border-radius: 10px;
|
||||
background: #faf8f5;
|
||||
}
|
||||
.m-order-submit-confirm dl > div {
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
.m-order-submit-confirm dt {
|
||||
flex: 0 0 auto;
|
||||
color: #777;
|
||||
}
|
||||
.m-order-submit-confirm dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
}
|
||||
.m-order-submit-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
border-top: 1px solid #f0ebe4;
|
||||
}
|
||||
.m-order-submit-actions button {
|
||||
min-height: 54px;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
.m-btn-link {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -1301,18 +1436,24 @@ body {
|
||||
background: #c86d45;
|
||||
}
|
||||
.m-chat {
|
||||
min-height: 100dvh;
|
||||
height: 100dvh;
|
||||
background: #f4f5f6;
|
||||
padding-bottom: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.m-chat .m-page-header {
|
||||
flex: 0 0 auto;
|
||||
padding-inline: 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.m-chat-time {
|
||||
flex: 0 0 auto;
|
||||
text-align: center;
|
||||
color: #92969b;
|
||||
padding: 8px;
|
||||
padding: 9px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.m-chat-empty {
|
||||
min-height: 530px;
|
||||
@@ -1337,43 +1478,404 @@ body {
|
||||
width: min(280px, 100%);
|
||||
margin-top: 16px;
|
||||
}
|
||||
.m-chat-input {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
transform: translateX(-50%);
|
||||
width: min(100%, 750px);
|
||||
height: 72px;
|
||||
.m-chat-composer {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
background: #f1f1f1;
|
||||
padding: 10px 15px calc(10px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid #e8e8e8;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.m-chat-input {
|
||||
min-height: 68px;
|
||||
padding: 10px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr 44px;
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.m-chat-input button {
|
||||
.m-chat-input .m-chat-send {
|
||||
min-width: 66px;
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 27px;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(135deg, #e2a25f, #d18437);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 3px 9px rgb(177 105 37 / 20%);
|
||||
}
|
||||
.m-chat-input button img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
.m-chat-input .m-chat-send:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.m-chat-tool {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
padding: 6px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.m-chat-tool img {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.m-chat-input input {
|
||||
min-width: 0;
|
||||
height: 46px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 0 12px;
|
||||
padding: 0 14px;
|
||||
outline: none;
|
||||
color: var(--m-ink);
|
||||
box-shadow: inset 0 0 0 1px transparent;
|
||||
}
|
||||
.m-chat-input input:focus {
|
||||
box-shadow: inset 0 0 0 1px #d99a55;
|
||||
}
|
||||
.m-chat-tools {
|
||||
min-height: 142px;
|
||||
padding: 10px 30px 18px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 82px);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
.m-chat-tools button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #7a7d80;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.m-chat-tools button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.m-chat-file-input {
|
||||
display: none;
|
||||
}
|
||||
.m-chat-tools .n-icon {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #555;
|
||||
font-size: 31px;
|
||||
}
|
||||
.m-chat-emoji-panel {
|
||||
min-height: 248px;
|
||||
padding: 12px 18px 20px;
|
||||
background: #fff;
|
||||
border-radius: 18px 18px 0 0;
|
||||
}
|
||||
.m-chat-emoji-tabs {
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.m-chat-emoji-tabs button {
|
||||
min-width: 54px;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: #8b929d;
|
||||
font-size: 15px;
|
||||
}
|
||||
.m-chat-emoji-tabs button.is-active {
|
||||
background: #e3efff;
|
||||
color: #1683ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.m-chat-emojis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 12px 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.m-chat-emojis button {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
.m-chat-emojis button:active {
|
||||
background: #eef4fb;
|
||||
transform: scale(0.94);
|
||||
}
|
||||
.m-chat-emoji-empty {
|
||||
min-height: 142px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #a1a7b0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.m-chat-uploading {
|
||||
height: 34px;
|
||||
padding: 0 18px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #8a633d;
|
||||
font-size: 12px;
|
||||
}
|
||||
.m-chat-uploading progress {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
accent-color: #d58d2b;
|
||||
}
|
||||
.m-chat-uploading b {
|
||||
flex: 0 0 auto;
|
||||
font-weight: 500;
|
||||
}
|
||||
.m-chat-messages {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px 20px 24px;
|
||||
}
|
||||
.m-chat-welcome {
|
||||
width: max-content;
|
||||
max-width: 90%;
|
||||
margin: 12px auto 20px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 14px;
|
||||
background: #e5e7e9;
|
||||
color: #74797e;
|
||||
font-size: 13px;
|
||||
}
|
||||
.m-chat-history-divider {
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #9a9ea4;
|
||||
font-size: 12px;
|
||||
}
|
||||
.m-chat-history-divider::before,
|
||||
.m-chat-history-divider::after {
|
||||
content: '';
|
||||
height: 1px;
|
||||
flex: 1;
|
||||
background: #e4e6e9;
|
||||
}
|
||||
.m-chat-session-tip {
|
||||
width: max-content;
|
||||
max-width: 90%;
|
||||
margin: 12px auto 20px;
|
||||
padding: 7px 13px;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
color: #647188;
|
||||
font-size: 13px;
|
||||
box-shadow: 0 2px 7px rgb(0 0 0 / 4%);
|
||||
}
|
||||
.m-chat-system-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
.m-chat-avatar--agent span,
|
||||
.m-chat-avatar > span {
|
||||
font-size: 23px;
|
||||
line-height: 1;
|
||||
}
|
||||
.m-chat-system-message {
|
||||
max-width: calc(100% - 52px);
|
||||
}
|
||||
.m-chat-system-message small {
|
||||
display: block;
|
||||
margin: 0 0 6px 2px;
|
||||
color: #68768d;
|
||||
font-size: 12px;
|
||||
}
|
||||
.m-chat-system-message p {
|
||||
margin: 0;
|
||||
padding: 13px 15px;
|
||||
border-radius: 4px 14px 14px;
|
||||
background: #fff;
|
||||
color: #e75b9f;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.65;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 5%);
|
||||
}
|
||||
.m-chat-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
.m-chat-row.is-mine {
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.m-chat-avatar {
|
||||
flex: 0 0 auto;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: #f3c883;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
}
|
||||
.m-chat-row.is-mine .m-chat-avatar {
|
||||
background: #62a7e8;
|
||||
}
|
||||
.m-chat-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.m-chat-avatar .n-icon {
|
||||
font-size: 26px;
|
||||
}
|
||||
.m-chat-message {
|
||||
max-width: calc(78% - 52px);
|
||||
}
|
||||
.m-chat-row.is-mine .m-chat-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.m-chat-bubble {
|
||||
position: relative;
|
||||
padding: 11px 14px;
|
||||
border-radius: 4px 14px 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 5%);
|
||||
}
|
||||
.m-chat-row.is-mine .m-chat-bubble {
|
||||
border-radius: 14px 4px 14px 14px;
|
||||
background: #dfa05f;
|
||||
}
|
||||
.m-chat-bubble p {
|
||||
margin: 0;
|
||||
color: var(--m-ink);
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.m-chat-row.is-mine .m-chat-bubble p {
|
||||
color: #fff;
|
||||
}
|
||||
.m-chat-bubble.is-media {
|
||||
padding: 4px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
.m-chat-media {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: min(280px, 100%);
|
||||
max-height: 320px;
|
||||
border-radius: 10px;
|
||||
object-fit: contain;
|
||||
background: #111;
|
||||
}
|
||||
.m-chat-message > small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: #92969b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.m-subpage {
|
||||
background: #fff;
|
||||
}
|
||||
.m-subpage--profile {
|
||||
background-color: #fffaf5;
|
||||
background-size: 100% auto;
|
||||
background-repeat: no-repeat;
|
||||
background-color: #f2f6fc;
|
||||
}
|
||||
.m-profile-info {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 20px 12px 32px;
|
||||
}
|
||||
.m-profile-info-card {
|
||||
padding: 0 18px;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
.m-profile-info-row {
|
||||
min-height: 66px;
|
||||
display: grid;
|
||||
grid-template-columns: 30px minmax(0, 1fr) auto 20px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid #e9eef5;
|
||||
color: #303846;
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
}
|
||||
.m-profile-info-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.m-profile-info-row > .n-icon:first-child {
|
||||
font-size: 24px;
|
||||
}
|
||||
.m-profile-info-row > .n-icon:last-child,
|
||||
.m-profile-avatar-row > .n-icon:nth-last-child(2) {
|
||||
grid-column: 4;
|
||||
font-size: 21px;
|
||||
}
|
||||
.m-profile-info-row > b {
|
||||
grid-column: 3 / 5;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.m-profile-avatar-row {
|
||||
min-height: 94px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.m-profile-avatar-row input {
|
||||
display: none;
|
||||
}
|
||||
.m-profile-avatar-preview {
|
||||
grid-column: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.m-profile-avatar-preview > img,
|
||||
.m-profile-avatar-preview > .n-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
font-size: 52px;
|
||||
}
|
||||
.m-profile-avatar-preview small {
|
||||
color: var(--m-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.m-profile-info > .m-form-error {
|
||||
margin: -10px 6px 0;
|
||||
}
|
||||
.m-bound-account-hint {
|
||||
margin: 16px 20px;
|
||||
color: var(--m-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
.m-profile-edit {
|
||||
text-align: center;
|
||||
@@ -1449,9 +1951,70 @@ body {
|
||||
color: var(--m-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.m-form-hint a {
|
||||
color: var(--m-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.m-plain-form > .m-form-error {
|
||||
margin: 15px 30px;
|
||||
}
|
||||
.m-bank-form {
|
||||
margin-top: 18px;
|
||||
padding-bottom: 38px;
|
||||
}
|
||||
.m-bank-form > label {
|
||||
min-height: 66px;
|
||||
grid-template-columns: 125px 1fr;
|
||||
border-bottom: 0;
|
||||
}
|
||||
.m-bank-form__divider {
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
background: #f6f6f6;
|
||||
}
|
||||
.m-qr-field {
|
||||
padding: 16px 15px 0;
|
||||
}
|
||||
.m-qr-field h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.m-qr-upload {
|
||||
width: 100%;
|
||||
aspect-ratio: 2.12 / 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px dashed #d1d1d1;
|
||||
border-radius: 5px;
|
||||
background: #f8f8f8;
|
||||
color: #b8b8b8;
|
||||
cursor: pointer;
|
||||
}
|
||||
.m-qr-upload input {
|
||||
display: none;
|
||||
}
|
||||
.m-qr-upload > span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.m-qr-upload .n-icon {
|
||||
font-size: 50px;
|
||||
color: #ddd;
|
||||
}
|
||||
.m-qr-upload b {
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.m-qr-upload img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.m-verify label:nth-child(2) {
|
||||
grid-template-columns: 125px 1fr auto;
|
||||
}
|
||||
@@ -1841,6 +2404,25 @@ body {
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.m-managed-rich-text {
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
line-height: 1.75;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.m-managed-rich-text :where(h1, h2, h3, h4) {
|
||||
margin: 1em 0 0.55em;
|
||||
color: inherit;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.m-managed-rich-text :where(p, ul, ol) {
|
||||
margin: 0 0 0.75em;
|
||||
}
|
||||
.m-managed-rich-text :where(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.m-agency-page {
|
||||
padding: 0 18px 32px;
|
||||
@@ -2080,6 +2662,7 @@ body {
|
||||
gap: 22px;
|
||||
margin-top: 20px;
|
||||
font-size: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.m-rules-banner strong {
|
||||
font-size: 31px;
|
||||
@@ -2091,10 +2674,12 @@ body {
|
||||
.m-rules-row > span:first-child,
|
||||
.m-rules-row > span:last-child {
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.m-rule-level {
|
||||
gap: 1px;
|
||||
flex-direction: column;
|
||||
white-space: normal;
|
||||
}
|
||||
.m-rule-level img {
|
||||
width: 22px;
|
||||
@@ -2253,9 +2838,29 @@ body {
|
||||
}
|
||||
}
|
||||
.m-withdraw-confirm {
|
||||
position: relative;
|
||||
width: min(90vw, 420px);
|
||||
padding: 22px;
|
||||
}
|
||||
.m-withdraw-confirm .m-withdraw-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--m-muted);
|
||||
cursor: pointer;
|
||||
font-size: 22px;
|
||||
}
|
||||
.m-withdraw-confirm .m-withdraw-close:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.m-withdraw-confirm h2 {
|
||||
margin: 0 0 12px;
|
||||
text-align: center;
|
||||
@@ -2282,6 +2887,16 @@ body {
|
||||
padding: 0 12px;
|
||||
outline: 0;
|
||||
}
|
||||
.m-withdraw-password-notice {
|
||||
margin: 16px 0;
|
||||
color: var(--m-muted);
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
.m-withdraw-confirm .m-withdraw-setup-btn,
|
||||
.m-withdraw-confirm .m-withdraw-back-btn {
|
||||
margin: 8px 0 14px;
|
||||
}
|
||||
|
||||
.m-finance-records {
|
||||
padding-inline: 0;
|
||||
@@ -2917,6 +3532,13 @@ body {
|
||||
background: #ffb21f;
|
||||
color: #fff;
|
||||
}
|
||||
.m-match-steps--single {
|
||||
margin-top: 4px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.m-match-steps--single li {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Olons 2 manual task mall */
|
||||
.m-task-mall {
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
<img class="m-agency-hero" :src="mobileAssets.agencyHero" alt="邀请好友成为平台代理" />
|
||||
|
||||
<section class="m-agency-block">
|
||||
<h2><i>1</i>什么是平台代理?</h2>
|
||||
<p>平台用户可通过推荐新人成为平台代理,代理能够获得额外动态奖励。</p>
|
||||
<p>被推荐用户获得每日佣金后,代理按对应层级比例获得奖励。</p>
|
||||
<h2><i>1</i>{{ content?.title || '什么是平台代理?' }}</h2>
|
||||
<div v-if="content" class="m-managed-rich-text" v-html="content.body_html"></div>
|
||||
<template v-else>
|
||||
<p>平台用户可通过推荐新人成为平台代理,代理能够获得额外动态奖励。</p>
|
||||
<p>被推荐用户获得每日佣金后,代理按对应层级比例获得奖励。</p>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="m-agency-block">
|
||||
@@ -48,12 +51,22 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import { request } from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
|
||||
const content = ref<{ title: string; body_html: string }>();
|
||||
|
||||
const rewards = [
|
||||
{ level: '一级用户', rate: '16%', className: 'is-primary', icon: mobileAssets.agencyPersonPrimary },
|
||||
{ level: '二级用户', rate: '8%', className: 'is-secondary', icon: mobileAssets.agencyPersonSecondary },
|
||||
{ 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>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
v-model.trim="form.username"
|
||||
autocomplete="username"
|
||||
maxlength="10"
|
||||
placeholder="请输入账户名"
|
||||
placeholder="请输入名字"
|
||||
/>
|
||||
</label>
|
||||
<label class="m-input-row">
|
||||
@@ -47,8 +47,10 @@
|
||||
<n-icon :component="MailOpenOutline" />
|
||||
<input
|
||||
v-model.trim="form.referral_code"
|
||||
maxlength="32"
|
||||
placeholder="请输入推荐码(选填)"
|
||||
maxlength="6"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
placeholder="请输入邀请码(必填)"
|
||||
/>
|
||||
</label>
|
||||
<router-link v-if="mode === 'login'" class="m-forgot-link" to="/app/password/verify"
|
||||
@@ -94,27 +96,47 @@
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const mode = ref<'login' | 'register'>('login');
|
||||
const accepted = ref(false);
|
||||
const accepted = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref('');
|
||||
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.phone = '';
|
||||
form.password = '';
|
||||
form.referral_code = '';
|
||||
accepted.value = false;
|
||||
form.referral_code = value === 'register' ? referralCodeFromLink() : '';
|
||||
accepted.value = true;
|
||||
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() {
|
||||
error.value = '';
|
||||
if (!/^1\d{10}$/.test(form.phone)) return void (error.value = '请输入正确的手机号码');
|
||||
if (form.password.length < 6) return void (error.value = '密码至少为 6 位');
|
||||
if (!accepted.value) return void (error.value = '请先阅读并同意用户协议和隐私政策');
|
||||
if (mode.value === 'register' && !/^[a-z0-9]{1,10}$/.test(form.username)) {
|
||||
return void (error.value = '账户名须为 1 至 10 位小写字母或数字');
|
||||
if (mode.value === 'register' && !/^[\p{L}\p{M}·]{1,10}$/u.test(form.username)) {
|
||||
return void (error.value = '名字须为 1 至 10 个字符,支持汉字');
|
||||
}
|
||||
if (mode.value === 'register' && !/^\d{6}$/.test(form.referral_code)) {
|
||||
return void (error.value = '请输入 6 位数字邀请码');
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<img :src="mobileAssets.claimMachine" alt="每日参与任务,赚取更多佣金" />
|
||||
<button
|
||||
type="button"
|
||||
:disabled="claiming || !halls.length || eligibility?.eligible === false"
|
||||
:disabled="claiming || !eligibleHalls.length"
|
||||
aria-label="自动领取任务"
|
||||
@click="claim"
|
||||
>
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="eligibility?.eligible === false" class="m-claim2-warning">
|
||||
<div v-if="halls.length && !eligibleHalls.length && eligibility?.eligible === false" class="m-claim2-warning">
|
||||
<n-icon :component="AlertCircleOutline" />
|
||||
<span>{{ eligibility.message || '暂不满足领取条件' }}</span>
|
||||
<button v-if="eligibility.pending_order_id" type="button" @click="openPendingOrder">查看任务</button>
|
||||
@@ -45,20 +45,18 @@
|
||||
<n-modal v-model:show="showResult" :mask-closable="false">
|
||||
<section class="m-claim-result" :class="`is-${resultState}`">
|
||||
<template v-if="resultState === 'matching'">
|
||||
<h2>正在匹配任务</h2>
|
||||
<h2>正在匹配订单</h2>
|
||||
<p>{{ matchingHint }}</p>
|
||||
<div class="m-match-visual">
|
||||
<img class="orbit" :src="mobileAssets.claimSpinner" alt="" />
|
||||
<img class="gift" :src="mobileAssets.claimMatchingGift" alt="" />
|
||||
</div>
|
||||
<div class="m-match-progress"><i :style="{ width: `${matchProgress}%` }"></i></div>
|
||||
<strong class="m-match-percent">{{ matchProgress }}%</strong>
|
||||
<ol class="m-match-steps">
|
||||
<li v-for="(step, index) in matchSteps" :key="step" :class="{ active: index <= matchStep }">
|
||||
<span>✓</span>{{ step }}
|
||||
</li>
|
||||
<div v-if="isAutoMatching" class="m-match-progress"><i :style="{ width: `${matchProgress}%` }"></i></div>
|
||||
<strong v-if="isAutoMatching" class="m-match-percent">{{ matchProgress }}%</strong>
|
||||
<ol class="m-match-steps m-match-steps--single">
|
||||
<li class="active"><span>✓</span>正在匹配订单</li>
|
||||
</ol>
|
||||
<button class="m-result-secondary" @click="openOrders">后台匹配,去记录查看</button>
|
||||
<button class="m-result-secondary" @click="openOrders">稍后去记录查看</button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
@@ -100,7 +98,7 @@
|
||||
import { useRouter } from 'vue-router';
|
||||
import { AlertCircleOutline, ImageOutline } from '@vicons/ionicons5';
|
||||
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';
|
||||
|
||||
type ResultState = 'matching' | 'success' | 'failed' | 'cancelled';
|
||||
@@ -112,14 +110,17 @@
|
||||
const halls = ref<any[]>([]);
|
||||
const stats = ref<any>();
|
||||
const eligibility = ref<any>();
|
||||
const eligibilityByHall = ref<Record<number, any>>({});
|
||||
const order = ref<any>();
|
||||
const showResult = ref(false);
|
||||
const resultState = ref<ResultState>('matching');
|
||||
const failureMessage = ref('当前任务库存未分配成功,请稍后重试');
|
||||
const matchProgress = ref(18);
|
||||
const matchRemainingSeconds = ref(0);
|
||||
const matchTotalSeconds = ref(0);
|
||||
const pollFinished = ref(false);
|
||||
let pollTimer: number | undefined;
|
||||
let progressTimer: number | undefined;
|
||||
let countdownTimer: number | undefined;
|
||||
let pollCount = 0;
|
||||
|
||||
const statCards = computed(() => [
|
||||
@@ -128,14 +129,20 @@
|
||||
{ label: '今日佣金', value: money(stats.value?.today_commission_amount), icon: mobileAssets.claimToday },
|
||||
{ label: '今日领取数', value: stats.value?.today_claim_count ?? 0, icon: mobileAssets.claimCount },
|
||||
]);
|
||||
const eligibleHalls = computed(() =>
|
||||
halls.value.filter((hall) => eligibilityByHall.value[hall.id]?.eligible)
|
||||
);
|
||||
const businessHours = computed(() => {
|
||||
const start = String(eligibility.value?.business_start_at || '09:00').slice(0, 5);
|
||||
const end = String(eligibility.value?.business_end_at || '21:00').slice(0, 5);
|
||||
return `${start}-${end}`;
|
||||
});
|
||||
const matchStep = computed(() => (matchProgress.value < 35 ? 0 : matchProgress.value < 60 ? 1 : matchProgress.value < 85 ? 2 : 3));
|
||||
const matchSteps = ['正在匹配任务', '获取商品信息', '确认任务内容', '发放任务结果'];
|
||||
const matchingHint = computed(() => pollFinished.value ? '任务仍在匹配,可稍后前往记录查看' : '系统正在为您匹配任务,请稍等...');
|
||||
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 resultIcon = computed(() => resultState.value === 'success' ? mobileAssets.claimSuccess : resultState.value === 'failed' ? mobileAssets.claimFailed : mobileAssets.claimCancelled);
|
||||
const resultTitle = computed(() => resultState.value === 'success' ? '任务获取成功' : resultState.value === 'failed' ? '领取失败' : '已取消任务');
|
||||
const resultDescription = computed(() => resultState.value === 'success' ? '恭喜你成功领取任务' : resultState.value === 'failed' ? '本次任务领取未成功,请稍后再试' : '本次任务已取消,可重新领取');
|
||||
@@ -154,7 +161,10 @@
|
||||
]);
|
||||
halls.value = hallData || [];
|
||||
stats.value = statData;
|
||||
if (halls.value.length) await loadEligibility(halls.value[0].id);
|
||||
if (halls.value.length) {
|
||||
await loadEligibilities();
|
||||
await resumeMatchingOrder();
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
@@ -162,8 +172,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEligibility(hallID: number) {
|
||||
eligibility.value = await request({ url: '/v1/tasks/eligibility', params: { hall_id: hallID } });
|
||||
async function loadEligibilities() {
|
||||
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() {
|
||||
@@ -171,18 +203,33 @@
|
||||
claiming.value = true;
|
||||
pollFinished.value = false;
|
||||
try {
|
||||
await loadEligibility(halls.value[0].id);
|
||||
if (!eligibility.value?.eligible) {
|
||||
await loadEligibilities();
|
||||
const candidates = eligibleHalls.value;
|
||||
if (!candidates.length) {
|
||||
window.$message.warning(eligibility.value?.message || '暂不满足领取条件');
|
||||
return;
|
||||
}
|
||||
openMatching();
|
||||
order.value = await request<any>({
|
||||
url: '/v1/task-orders/claim',
|
||||
method: 'POST',
|
||||
data: { hall_id: halls.value[0].id },
|
||||
});
|
||||
handleOrder(order.value);
|
||||
let stockError: unknown;
|
||||
for (const hall of candidates) {
|
||||
eligibility.value = eligibilityByHall.value[hall.id];
|
||||
try {
|
||||
order.value = await request<any>({
|
||||
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) {
|
||||
failureMessage.value = errorText(err);
|
||||
resultState.value = 'failed';
|
||||
@@ -198,15 +245,23 @@
|
||||
resultState.value = 'matching';
|
||||
showResult.value = true;
|
||||
matchProgress.value = 18;
|
||||
matchRemainingSeconds.value = 0;
|
||||
matchTotalSeconds.value = 0;
|
||||
pollCount = 0;
|
||||
progressTimer = window.setInterval(() => {
|
||||
matchProgress.value = Math.min(88, matchProgress.value + Math.ceil(Math.random() * 5));
|
||||
}, 700);
|
||||
}
|
||||
|
||||
function handleOrder(value: any) {
|
||||
order.value = value;
|
||||
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();
|
||||
} else if (['pending', 'processing', 'completed'].includes(value?.status)) {
|
||||
matchProgress.value = 100;
|
||||
@@ -224,6 +279,13 @@
|
||||
|
||||
function schedulePoll() {
|
||||
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 () => {
|
||||
pollCount += 1;
|
||||
try {
|
||||
@@ -238,14 +300,44 @@
|
||||
pollFinished.value = true;
|
||||
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) {
|
||||
if (pollTimer) window.clearTimeout(pollTimer);
|
||||
if (clearProgress && progressTimer) window.clearInterval(progressTimer);
|
||||
if (clearProgress && countdownTimer) window.clearInterval(countdownTimer);
|
||||
pollTimer = undefined;
|
||||
if (clearProgress) progressTimer = undefined;
|
||||
if (clearProgress) {
|
||||
countdownTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function closeResult() {
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
<section
|
||||
class="m-page m-page--white m-subpage"
|
||||
:class="{ 'm-subpage--profile': formType === 'profile' }"
|
||||
:style="
|
||||
formType === 'profile'
|
||||
? { backgroundImage: `url(${mobileAssets.profileBackground})` }
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<PageHeader :title="title" />
|
||||
<AsyncState
|
||||
@@ -15,71 +10,94 @@
|
||||
:error="error"
|
||||
@retry="loadProfile"
|
||||
>
|
||||
<div class="m-profile-edit">
|
||||
<div class="m-avatar m-avatar--large"
|
||||
><img
|
||||
v-if="safeImage(me?.avatar_url)"
|
||||
:src="safeImage(me.avatar_url)"
|
||||
alt="用户头像" /><n-icon v-else :component="PersonCircleOutline" /><img
|
||||
class="m-avatar-edit"
|
||||
:src="mobileAssets.avatarEdit"
|
||||
alt=""
|
||||
/></div>
|
||||
<div class="m-info-card m-card">
|
||||
<div
|
||||
><span>昵称</span><b>{{ me?.username }}</b></div
|
||||
><div
|
||||
><span>手机号码</span><b>{{ me?.phone }}</b></div
|
||||
><div
|
||||
><span>邀请码</span><b>{{ me?.invite_code }}</b></div
|
||||
>
|
||||
<router-link to="/app/profile/password"
|
||||
><span>密码管理</span><n-icon :component="ChevronForwardOutline"
|
||||
/></router-link>
|
||||
<router-link to="/app/profile/trade-password"
|
||||
><span>交易密码管理</span><n-icon :component="ChevronForwardOutline"
|
||||
/></router-link>
|
||||
<div class="m-profile-info">
|
||||
<div class="m-profile-info-card m-card">
|
||||
<label class="m-profile-info-row m-profile-avatar-row">
|
||||
<n-icon :component="ImageOutline" />
|
||||
<span>头像</span>
|
||||
<span class="m-profile-avatar-preview">
|
||||
<img v-if="safeImage(me?.avatar_url)" :src="safeImage(me.avatar_url)" alt="用户头像" />
|
||||
<n-icon v-else :component="PersonCircleOutline" />
|
||||
<small v-if="avatarUploading">上传中...</small>
|
||||
</span>
|
||||
<n-icon :component="ChevronForwardOutline" />
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" :disabled="avatarUploading" @change="uploadAvatar" />
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="avatarError" class="m-form-error">{{ avatarError }}</p>
|
||||
<div class="m-profile-info-card m-card">
|
||||
<div class="m-profile-info-row">
|
||||
<n-icon :component="PhonePortraitOutline" />
|
||||
<span>我的手机号</span>
|
||||
<b>{{ me?.phone }}</b>
|
||||
</div>
|
||||
<router-link class="m-profile-info-row" to="/app/profile/bank-card?from=profile">
|
||||
<n-icon :component="CardOutline" />
|
||||
<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>
|
||||
</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
|
||||
><span>姓名</span><input v-model.trim="bank.holder_name" placeholder="请输入姓名"
|
||||
/></label>
|
||||
<label
|
||||
><span>身份证号</span
|
||||
><input v-model.trim="bank.identity_no" maxlength="18" placeholder="请输入你的身份证号"
|
||||
/></label>
|
||||
<div class="m-bank-form__divider"></div>
|
||||
<label
|
||||
><span>银行卡号</span
|
||||
><input v-model.trim="bank.account_no" inputmode="numeric" placeholder="请输入银行卡号"
|
||||
><input v-model.trim="bank.account_no" inputmode="numeric" placeholder="请输入你的银行卡号"
|
||||
/></label>
|
||||
<label
|
||||
><span>开户行</span><input v-model.trim="bank.bank_name" placeholder="请输入开户行"
|
||||
/></label>
|
||||
<label
|
||||
><span>银行编码</span><input v-model.trim="bank.bank_code" placeholder="例如 ICBC"
|
||||
/></label>
|
||||
<label
|
||||
><span>预留手机号</span
|
||||
><span>数字人民币</span
|
||||
><input
|
||||
v-model.trim="bank.reserved_phone"
|
||||
v-model.trim="bank.digital_rmb_code"
|
||||
inputmode="numeric"
|
||||
maxlength="11"
|
||||
placeholder="请输入预留手机号(选填)"
|
||||
maxlength="32"
|
||||
placeholder="请输入数字编码(选填)"
|
||||
/></label>
|
||||
<label
|
||||
><span>交易密码</span
|
||||
><input
|
||||
v-model="bank.trade_password"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="请输入 6 位交易密码"
|
||||
/></label>
|
||||
<p class="m-form-hint">微信、支付宝二维码上传能力将在上传接口开放后提供。</p>
|
||||
<section class="m-qr-field">
|
||||
<h3>微信二维码</h3>
|
||||
<label class="m-qr-upload">
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" @change="uploadQRCode('wechat', $event)" />
|
||||
<img v-if="qr.wechat.url" :src="safeImage(qr.wechat.url)" alt="微信二维码" />
|
||||
<span v-else><n-icon :component="CameraOutline" /><b>{{ qr.wechat.uploading ? '上传中...' : bank.wechat_qr_attachment_id ? '已上传,点击更换' : '点击上传' }}</b></span>
|
||||
</label>
|
||||
</section>
|
||||
<section class="m-qr-field">
|
||||
<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
|
||||
><button class="m-primary-btn" :disabled="submitting">{{
|
||||
submitting ? '提交中...' : '确认绑定'
|
||||
><button class="m-primary-btn" :disabled="submitting || qr.wechat.uploading || qr.alipay.uploading">{{
|
||||
submitting ? '提交中...' : existingAccount ? '保存修改' : '确认绑定'
|
||||
}}</button>
|
||||
</form>
|
||||
</AsyncState>
|
||||
|
||||
<form
|
||||
v-else-if="formType === 'tradePassword'"
|
||||
@@ -195,7 +213,8 @@
|
||||
<article v-if="content" class="m-content"
|
||||
><h1>{{ content.title }}</h1
|
||||
><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"
|
||||
>发布时间:{{ dateTime(content.published_at) }}</time
|
||||
></article
|
||||
@@ -205,18 +224,21 @@
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
CardOutline,
|
||||
CameraOutline,
|
||||
ChevronForwardOutline,
|
||||
ImageOutline,
|
||||
KeyOutline,
|
||||
LockClosedOutline,
|
||||
PersonCircleOutline,
|
||||
PhonePortraitOutline,
|
||||
} from '@vicons/ionicons5';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { clearSession, dateTime, errorText, request, safeImage } from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const formType = computed(() => String(route.meta.form || ''));
|
||||
@@ -233,20 +255,42 @@
|
||||
String(route.meta.title || '')
|
||||
);
|
||||
const loading = ref(false);
|
||||
const bankLoading = ref(false);
|
||||
const bankLoadError = ref('');
|
||||
const submitting = ref(false);
|
||||
const error = ref('');
|
||||
const avatarError = ref('');
|
||||
const avatarUploading = ref(false);
|
||||
const me = ref<any>();
|
||||
interface PaymentAccountDetail {
|
||||
id: number;
|
||||
bank_name: string;
|
||||
holder_name: string;
|
||||
identity_no: 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 resetPassword = ref('');
|
||||
const bank = reactive({
|
||||
account_type: 'bank_card',
|
||||
bank_code: '',
|
||||
bank_name: '',
|
||||
holder_name: '',
|
||||
identity_no: '',
|
||||
account_no: '',
|
||||
reserved_phone: '',
|
||||
trade_password: '',
|
||||
qr_attachment_id: 0,
|
||||
digital_rmb_code: '',
|
||||
wechat_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 loginPassword = reactive({ old_password: '', new_password: '', confirm_password: '' });
|
||||
@@ -262,28 +306,134 @@
|
||||
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: '', identity_no: '', 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.identity_no = account.identity_no;
|
||||
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() {
|
||||
error.value = '';
|
||||
if (
|
||||
!bank.holder_name ||
|
||||
!/^[0-9 -]{8,36}$/.test(bank.account_no) ||
|
||||
!bank.bank_name ||
|
||||
!/^[0-9]{6}$/.test(bank.trade_password)
|
||||
) {
|
||||
error.value = '请完整填写姓名、银行卡、开户行和 6 位交易密码';
|
||||
const identityNo = bank.identity_no.replace(/[ -]/g, '');
|
||||
const accountNo = bank.account_no.replace(/[ -]/g, '');
|
||||
const digitalRMBCode = bank.digital_rmb_code.replace(/[ -]/g, '');
|
||||
const validationError =
|
||||
bank.holder_name.length < 2
|
||||
? '请输入至少 2 个字符的姓名'
|
||||
: !/^[1-9]\d{14}(?:\d{2}[\dXx])?$/.test(identityNo)
|
||||
? '请输入正确的 15 位或 18 位身份证号'
|
||||
: !/^\d{8,32}$/.test(accountNo)
|
||||
? '请输入 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;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await request({ url: '/v1/payment-accounts', method: 'POST', data: bank });
|
||||
window.$message?.success('提交成功,等待后台核验');
|
||||
router.back();
|
||||
await request({ url: '/v1/payment-accounts', method: existingAccount.value ? 'PUT' : 'POST',
|
||||
data: { ...bank, id: existingAccount.value?.id } });
|
||||
window.$message?.success(existingAccount.value ? '绑定信息已更新' : '银行卡绑定成功');
|
||||
await router.replace(route.query.from === 'profile' ? '/app/profile/info' : '/app/profile/withdrawal');
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
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() {
|
||||
error.value = '';
|
||||
if (!/^\d{6}$/.test(trade.new_trade_password) || trade.login_password.length < 6) {
|
||||
@@ -375,8 +525,16 @@
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
if (formType.value === 'profile') loadProfile();
|
||||
else if (formType.value === 'content') loadContent();
|
||||
});
|
||||
watch(
|
||||
() => [formType.value, String(route.params.key || '')],
|
||||
([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>
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<template>
|
||||
<section class="m-home">
|
||||
<AnnouncementPopup
|
||||
:show="Boolean(popupNotice)"
|
||||
:notice="popupNotice"
|
||||
@read="markPopupRead"
|
||||
/>
|
||||
<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">
|
||||
<n-icon :component="SearchOutline" />
|
||||
<input readonly placeholder="搜索活动、商品、问题等" @click="router.push('/app/mall')" />
|
||||
@@ -16,6 +28,16 @@
|
||||
>
|
||||
<div class="m-coin" aria-hidden="true">¥</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 class="m-home-body">
|
||||
@@ -77,18 +99,28 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { SearchOutline, MegaphoneOutline } from '@vicons/ionicons5';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { errorText, money, request, safeImage } from '../api';
|
||||
import AnnouncementPopup, {
|
||||
type AnnouncementNotice,
|
||||
} from '../components/AnnouncementPopup.vue';
|
||||
import {
|
||||
completeLoginNotice,
|
||||
errorText,
|
||||
money,
|
||||
request,
|
||||
safeImage,
|
||||
shouldShowLoginNotice,
|
||||
} from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
|
||||
interface HomeData {
|
||||
profile: { username: string; avatar_url: string; level_name: string };
|
||||
wallet: { available_amount: number; frozen_amount: number; currency: string };
|
||||
features: Record<string, boolean>;
|
||||
banners: Array<{ image_url?: string; title?: string }>;
|
||||
banners: BannerData[];
|
||||
notices: Array<{ title?: string; content?: string }>;
|
||||
task_summary: {
|
||||
today_count: number;
|
||||
@@ -96,6 +128,13 @@
|
||||
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 LeaderboardEntry {
|
||||
rank: number;
|
||||
username: string;
|
||||
@@ -108,9 +147,13 @@
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const leaderboard = ref<LeaderboardEntry[]>([]);
|
||||
const popupNotice = ref<AnnouncementNotice | null>(null);
|
||||
const activeBannerIndex = ref(0);
|
||||
let bannerTimer: number | undefined;
|
||||
const formatCount = (value: number) => new Intl.NumberFormat('zh-CN').format(value || 0);
|
||||
const currentBanner = computed(() => home.value?.banners?.[activeBannerIndex.value]);
|
||||
const bannerStyle = computed(() => {
|
||||
const url = safeImage(home.value?.banners?.[0]?.image_url);
|
||||
const url = safeImage(currentBanner.value?.image_url);
|
||||
return {
|
||||
backgroundImage: url
|
||||
? `linear-gradient(180deg, rgba(16,13,8,.18), rgba(16,13,8,.54)), url(${url})`
|
||||
@@ -162,21 +205,69 @@
|
||||
if (item.enabled) router.push(item.path);
|
||||
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);
|
||||
}
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const [homeData, rankData] = await Promise.all([
|
||||
const noticeRequest = shouldShowLoginNotice()
|
||||
? request<AnnouncementNotice | null>({ url: '/v1/notices/popup' }).catch(() => null)
|
||||
: Promise.resolve(null);
|
||||
const [homeData, rankData, noticeData] = await Promise.all([
|
||||
request<HomeData>({ url: '/v1/home' }),
|
||||
request<LeaderboardEntry[]>({ url: '/v1/leaderboard' }).catch(() => []),
|
||||
noticeRequest,
|
||||
]);
|
||||
home.value = homeData;
|
||||
leaderboard.value = rankData || [];
|
||||
popupNotice.value = noticeData;
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function markPopupRead() {
|
||||
if (!popupNotice.value) return;
|
||||
const noticeId = popupNotice.value.id;
|
||||
popupNotice.value = null;
|
||||
completeLoginNotice();
|
||||
if (noticeId > 0) {
|
||||
request<void>({ url: `/v1/notices/${noticeId}/read`, method: 'POST' }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
watch(() => home.value?.banners?.length, () => {
|
||||
activeBannerIndex.value = 0;
|
||||
restartBannerTimer();
|
||||
});
|
||||
onMounted(load);
|
||||
onBeforeUnmount(() => {
|
||||
if (bannerTimer) window.clearInterval(bannerTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -103,20 +103,56 @@
|
||||
><b>{{ detail.product?.specification || detail.product?.spec || '--' }}</b></div
|
||||
>
|
||||
</section>
|
||||
<section v-if="canSubmit" class="m-order-submit-panel">
|
||||
<div>
|
||||
<strong>{{ balanceInsufficient ? '账户余额不足' : '大额订单已匹配' }}</strong>
|
||||
<p v-if="balanceInsufficient">
|
||||
当前可用余额 {{ money(availableAmount, detail.currency) }},订单需支付
|
||||
{{ money(requiredAmount, detail.currency) }},还差
|
||||
{{ money(balanceShortfall, detail.currency) }}。请先储入后再提交。
|
||||
</p>
|
||||
<p v-else>当前余额充足,请确认订单信息后提交。</p>
|
||||
</div>
|
||||
<router-link v-if="balanceInsufficient" class="m-order-recharge-link" to="/app/recharge">
|
||||
去储入
|
||||
</router-link>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="submitting || balanceInsufficient"
|
||||
@click="showSubmitConfirm = true"
|
||||
>{{ balanceInsufficient ? '余额不足,无法提交' : '提交确认' }}</button>
|
||||
</section>
|
||||
</div>
|
||||
</AsyncState>
|
||||
|
||||
<n-modal v-model:show="showSubmitConfirm" :mask-closable="!submitting">
|
||||
<section class="m-order-submit-confirm">
|
||||
<h2>确认提交订单</h2>
|
||||
<p>系统将校验账户余额并完成订单;余额不足时需要先前往储入。</p>
|
||||
<dl>
|
||||
<div><dt>记录编号</dt><dd>{{ detail?.order_no || '--' }}</dd></div>
|
||||
<div><dt>任务商品</dt><dd>{{ detail?.product?.name || '--' }}</dd></div>
|
||||
<div><dt>预计佣金</dt><dd>{{ money(detail?.commission_amount, detail?.currency) }}</dd></div>
|
||||
</dl>
|
||||
<div class="m-order-submit-actions">
|
||||
<button type="button" :disabled="submitting" @click="showSubmitConfirm = false">暂不提交</button>
|
||||
<button type="button" :disabled="submitting" @click="submitOrder">{{ submitting ? '提交中...' : '确认提交' }}</button>
|
||||
</div>
|
||||
</section>
|
||||
</n-modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ImageOutline } from '@vicons/ionicons5';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { dateTime, errorText, money, request, safeImage } from '../api';
|
||||
import { MobileApiError, dateTime, errorText, money, request, safeImage } from '../api';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const isDetail = computed(() => Boolean(route.params.id));
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
@@ -124,6 +160,8 @@
|
||||
const orders = ref<any[]>([]);
|
||||
const detail = ref<any>();
|
||||
const stats = ref<any>();
|
||||
const showSubmitConfirm = ref(false);
|
||||
const submitting = ref(false);
|
||||
let listController: AbortController | undefined;
|
||||
const tabs = [
|
||||
{ label: '全部', value: 'all' },
|
||||
@@ -149,6 +187,17 @@
|
||||
failed: '失败',
|
||||
};
|
||||
const statusText = (value: string) => labels[value] || value;
|
||||
const canSubmit = computed(
|
||||
() => detail.value?.status === 'pending' && detail.value?.allowed_actions?.includes('submit')
|
||||
);
|
||||
const availableAmount = computed(() => Number(stats.value?.available_amount || 0));
|
||||
const requiredAmount = computed(() => Number(detail.value?.total_amount || 0));
|
||||
const balanceShortfall = computed(() =>
|
||||
Math.max(0, requiredAmount.value - availableAmount.value)
|
||||
);
|
||||
const balanceInsufficient = computed(
|
||||
() => canSubmit.value && availableAmount.value < requiredAmount.value
|
||||
);
|
||||
async function loadList() {
|
||||
listController?.abort();
|
||||
const controller = new AbortController();
|
||||
@@ -178,18 +227,57 @@
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
detail.value = await request({ url: `/v1/task-orders/${route.params.id}` });
|
||||
const [orderDetail, stat] = await Promise.all([
|
||||
request<any>({ url: `/v1/task-orders/${route.params.id}` }),
|
||||
request<any>({ url: '/v1/tasks/stats' }),
|
||||
]);
|
||||
detail.value = orderDetail;
|
||||
stats.value = stat;
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
async function submitOrder() {
|
||||
if (!detail.value?.id || submitting.value) return;
|
||||
if (balanceInsufficient.value) {
|
||||
showSubmitConfirm.value = false;
|
||||
window.$message.warning('账户余额不足,请先完成储入');
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await request({
|
||||
url: `/v1/task-orders/${detail.value.id}/submissions`,
|
||||
method: 'POST',
|
||||
data: {},
|
||||
});
|
||||
showSubmitConfirm.value = false;
|
||||
window.$message.success('提交成功,佣金已自动入账');
|
||||
await loadDetail();
|
||||
} catch (err) {
|
||||
if (err instanceof MobileApiError && err.reason === 'INSUFFICIENT_BALANCE') {
|
||||
showSubmitConfirm.value = false;
|
||||
window.$message.warning('账户余额不足,请先完成储入');
|
||||
await router.push('/app/recharge');
|
||||
return;
|
||||
}
|
||||
window.$message.error(errorText(err));
|
||||
if (err instanceof MobileApiError && err.reason === 'ORDER_EXPIRED') {
|
||||
showSubmitConfirm.value = false;
|
||||
await loadDetail();
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
function changeTab(value: string) {
|
||||
status.value = value;
|
||||
loadList();
|
||||
}
|
||||
function load() {
|
||||
showSubmitConfirm.value = false;
|
||||
if (isDetail.value) loadDetail();
|
||||
else loadList();
|
||||
}
|
||||
|
||||
@@ -2,47 +2,76 @@
|
||||
<section class="m-platform-intro">
|
||||
<PageHeader title="平台介绍" />
|
||||
|
||||
<header class="m-platform-intro-head">
|
||||
<span>OLONS PLATFORM</span>
|
||||
<h2>平台简介</h2>
|
||||
<p>{{ platformIntroLead }}</p>
|
||||
<div><b>{{ platformIntroSections.length }}</b><small>服务条款</small><i></i><b>{{ platformFAQs.length }}</b><small>常见问题</small></div>
|
||||
</header>
|
||||
<AsyncState :loading="loading" :error="error" :empty="!intro" empty-text="暂无平台介绍" @retry="load">
|
||||
<template v-if="intro">
|
||||
<header class="m-platform-intro-head">
|
||||
<span>YUEXUANLI</span>
|
||||
<h2>{{ intro.title }}</h2>
|
||||
<p>{{ intro.summary }}</p>
|
||||
<div><b>01</b><small>服务条款</small><i></i><b>02</b><small>常见问题</small></div>
|
||||
</header>
|
||||
|
||||
<nav class="m-platform-intro-nav" aria-label="页面目录">
|
||||
<button :class="{ active: activeSection === 'service-terms' }" @click="scrollToSection('service-terms')">服务条款</button>
|
||||
<button :class="{ active: activeSection === 'common-questions' }" @click="scrollToSection('common-questions')">常见问题</button>
|
||||
</nav>
|
||||
<nav class="m-platform-intro-nav" aria-label="页面目录">
|
||||
<button :class="{ active: activeSection === 'service-terms' }" @click="scrollToSection('service-terms')">服务条款</button>
|
||||
<button :class="{ active: activeSection === 'common-questions' }" @click="scrollToSection('common-questions')">常见问题</button>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<section id="service-terms" class="m-platform-document">
|
||||
<div class="m-platform-section-title"><span>01</span><div><small>TERMS</small><h2>服务条款</h2></div></div>
|
||||
<article v-for="section in platformIntroSections" :key="section.title">
|
||||
<h3>{{ section.title }}</h3>
|
||||
<p v-for="paragraph in section.paragraphs" :key="paragraph">{{ paragraph }}</p>
|
||||
</article>
|
||||
</section>
|
||||
<main>
|
||||
<section id="service-terms" class="m-platform-document">
|
||||
<div class="m-platform-section-title"><span>01</span><div><small>TERMS</small><h2>{{ intro.title }}</h2></div></div>
|
||||
<article class="m-managed-rich-text" v-html="intro.body_html"></article>
|
||||
</section>
|
||||
|
||||
<section id="common-questions" class="m-platform-document m-platform-faq">
|
||||
<div class="m-platform-section-title"><span>02</span><div><small>FAQ</small><h2>常见问题</h2></div></div>
|
||||
<article v-for="item in platformFAQs" :key="item.title">
|
||||
<h3>{{ item.title }}</h3>
|
||||
<p v-for="paragraph in item.paragraphs" :key="paragraph">{{ paragraph }}</p>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
<section id="common-questions" class="m-platform-document m-platform-faq">
|
||||
<div class="m-platform-section-title"><span>02</span><div><small>FAQ</small><h2>{{ faq?.title || '常见问题' }}</h2></div></div>
|
||||
<article v-if="faq" class="m-managed-rich-text" v-html="faq.body_html"></article>
|
||||
<article v-else><p>暂无常见问题。</p></article>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
</AsyncState>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import { platformFAQs, platformIntroLead, platformIntroSections } from './platformIntroContent';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { errorText, request } from '../api';
|
||||
|
||||
interface ManagedContent {
|
||||
title: string;
|
||||
summary: string;
|
||||
body_html: string;
|
||||
}
|
||||
|
||||
const activeSection = ref('service-terms');
|
||||
const intro = ref<ManagedContent>();
|
||||
const faq = ref<ManagedContent>();
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
|
||||
function scrollToSection(id: string) {
|
||||
activeSection.value = id;
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const [introData, faqData] = await Promise.all([
|
||||
request<ManagedContent>({ url: '/v1/contents/platform_introduction' }),
|
||||
request<ManagedContent>({ url: '/v1/contents/frequently_asked_questions' }).catch(() => undefined),
|
||||
]);
|
||||
intro.value = introData;
|
||||
faq.value = faqData;
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
@@ -45,11 +45,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { errorText, request } from '../api';
|
||||
|
||||
const levels = ref<any[]>([]);
|
||||
const router = useRouter();
|
||||
const channels = ref<any[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
@@ -104,9 +106,10 @@
|
||||
remark: `姓名:${form.name}`,
|
||||
},
|
||||
});
|
||||
window.$message?.success(`储入订单 ${order.recharge_no} 已创建`);
|
||||
window.$message?.success(`储入申请 ${order.recharge_no} 已提交审核`);
|
||||
form.amount = '';
|
||||
form.name = '';
|
||||
await router.push({ name: 'MobileSupport' });
|
||||
} catch (err) {
|
||||
formError.value = errorText(err);
|
||||
} finally {
|
||||
|
||||
@@ -5,47 +5,93 @@
|
||||
>
|
||||
<PageHeader title="规则说明" />
|
||||
|
||||
<div class="m-rules-banner" :style="{ backgroundImage: `url(${mobileAssets.rulesBanner})` }">
|
||||
<span>充值赠送金</span>
|
||||
<p><b>充值<strong>500元</strong></b><b>赠送<strong>20元</strong></b></p>
|
||||
</div>
|
||||
|
||||
<div class="m-rules-table" role="table" aria-label="会员规则">
|
||||
<div class="m-rules-row m-rules-head" role="row">
|
||||
<b>充值金额</b><b>会员等级</b><b>佣金比例</b><b>每日代购</b><b>额外奖励</b><b>单次提现</b>
|
||||
<AsyncState :loading="loading" :error="error" :empty="!levels.length" empty-text="暂无生效的会员等级" @retry="load">
|
||||
<div v-if="entryLevel" class="m-rules-banner" :style="{ backgroundImage: `url(${mobileAssets.rulesBanner})` }">
|
||||
<span>{{ entryLevel.name }}等级配置</span>
|
||||
<p><b>升级价格<strong>{{ yuan(entryLevel.upgrade_amount) }}</strong></b><b>赠送金额<strong>{{ yuan(entryLevel.gift_amount) }}</strong></b></p>
|
||||
</div>
|
||||
<div v-for="level in levels" :key="level.name" class="m-rules-row" role="row">
|
||||
<span>{{ level.amount }}</span>
|
||||
<span class="m-rule-level"><img :src="level.icon" alt="" />{{ level.name }}</span>
|
||||
<span>{{ level.rate }}</span>
|
||||
<span>{{ level.orders }}</span>
|
||||
<span>{{ level.bonus }}</span>
|
||||
<span>{{ level.withdrawal }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="m-rules-notes">
|
||||
<h2><span>03</span> 规则补充说明</h2>
|
||||
<ul>
|
||||
<li>达到对应单次充值门槛,赠送所列永久会员等级。</li>
|
||||
<li>每日代购数量、额外奖励及提现额度以对应档位为准。</li>
|
||||
<li>星耀、至尊档位原文佣金比例前后不一致,需核实。</li>
|
||||
<li>奖励领取条件、实际结算及提现限制请以正式规则为准。</li>
|
||||
</ul>
|
||||
</section>
|
||||
<div class="m-rules-table" role="table" aria-label="会员规则">
|
||||
<div class="m-rules-row m-rules-head" role="row">
|
||||
<b>升级价格</b><b>会员等级</b><b>订单佣金</b><b>每日接单</b><b>赠送金额</b><b>每日提现次数</b>
|
||||
</div>
|
||||
<div v-for="level in levels" :key="level.id" class="m-rules-row" role="row">
|
||||
<span>≥ {{ yuan(level.upgrade_amount) }}</span>
|
||||
<span class="m-rule-level"><img :src="level.icon_url || fallbackIcon(level.code)" alt="" />{{ level.name }}</span>
|
||||
<span>{{ percent(level.commission_rate) }}</span>
|
||||
<span>{{ level.daily_order_limit }}单</span>
|
||||
<span>{{ yuan(level.gift_amount) }}</span>
|
||||
<span>{{ level.daily_withdraw_limit }}次</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="m-rules-notes">
|
||||
<h2><span>03</span> {{ ruleContent?.title || '规则补充说明' }}</h2>
|
||||
<div v-if="ruleContent" class="m-managed-rich-text" v-html="ruleContent.body_html"></div>
|
||||
<ul v-else>
|
||||
<li>本页展示后台当前生效的会员等级配置,后台调整后重新进入页面即可查看新规则。</li>
|
||||
<li>订单佣金按订单金额 × 匹配时会员等级的佣金比例计算,实际金额以订单详情为准。</li>
|
||||
<li>每日接单、每日提现均为次数限制;赠送金额为等级配置,实际入账以账户明细为准。</li>
|
||||
</ul>
|
||||
</section>
|
||||
</AsyncState>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { errorText, request } from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
|
||||
const levels = [
|
||||
{ amount: '≥ 1888元', name: '白银', rate: '0.02', orders: '20单', bonus: '88元', withdrawal: '5000以下', icon: mobileAssets.levelSilver },
|
||||
{ amount: '≥ 10000元', name: '黄金', rate: '0.022', orders: '30单', bonus: '188元', withdrawal: '20000以下', icon: mobileAssets.levelGold },
|
||||
{ amount: '≥ 30000元', name: '铂金', rate: '0.03', orders: '40单', bonus: '388元', withdrawal: '30000以下', icon: mobileAssets.levelPlatinum },
|
||||
{ amount: '≥ 50000元', name: '钻石', rate: '0.035', orders: '50单', bonus: '588元', withdrawal: '50000以下', icon: mobileAssets.levelDiamond },
|
||||
{ amount: '≥ 80000元', name: '星耀', rate: '0.04', orders: '80单', bonus: '1888元', withdrawal: '100000以下', icon: mobileAssets.levelStar },
|
||||
{ amount: '≥ 180000元', name: '至尊', rate: '0.05', orders: '100单', bonus: '2888元', withdrawal: '无限制', icon: mobileAssets.levelCrown },
|
||||
];
|
||||
interface MemberLevel {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
icon_url: string;
|
||||
upgrade_amount: number;
|
||||
gift_amount: number;
|
||||
daily_order_limit: number;
|
||||
commission_rate: number;
|
||||
daily_withdraw_limit: number;
|
||||
}
|
||||
|
||||
const levels = ref<MemberLevel[]>([]);
|
||||
const ruleContent = ref<{ title: string; body_html: string }>();
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const entryLevel = computed(() => levels.value[0]);
|
||||
const icons: Record<string, string> = {
|
||||
VIP1: mobileAssets.levelSilver,
|
||||
VIP2: mobileAssets.levelSilver,
|
||||
VIP3: mobileAssets.levelGold,
|
||||
VIP4: mobileAssets.levelPlatinum,
|
||||
VIP5: mobileAssets.levelDiamond,
|
||||
VIP6: mobileAssets.levelStar,
|
||||
VIP7: mobileAssets.levelCrown,
|
||||
};
|
||||
|
||||
const yuan = (cents: number) => `${(Number(cents || 0) / 100).toLocaleString('zh-CN', { maximumFractionDigits: 2 })}元`;
|
||||
const percent = (basisPoints: number) => `${(Number(basisPoints || 0) / 100).toFixed(2)}%`;
|
||||
const fallbackIcon = (code: string) => icons[code] || mobileAssets.levelSilver;
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const [levelData, contentData] = await Promise.all([
|
||||
request<MemberLevel[]>({ url: '/v1/member-levels' }),
|
||||
request<{ title: string; body_html: string }>({ url: '/v1/contents/task_order_rules' }).catch(() => undefined),
|
||||
]);
|
||||
levels.value = levelData;
|
||||
ruleContent.value = contentData;
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
@@ -37,64 +37,198 @@
|
||||
|
||||
<section v-else class="m-chat">
|
||||
<PageHeader :title="current?.name || '在线客服'" />
|
||||
<div class="m-chat-time">会话内容由客服系统提供</div>
|
||||
<div class="m-chat-empty">
|
||||
<div class="m-chat-time">会话内容已安全加密,请勿发送密码或验证码</div>
|
||||
<div v-if="chatLoading" class="m-chat-empty">
|
||||
<n-icon :component="ChatbubbleEllipsesOutline" />
|
||||
<h2>正在连接客服</h2>
|
||||
<p>{{ current?.privacy_notice || '请勿在聊天中发送登录密码或支付密码' }}</p>
|
||||
<a
|
||||
v-if="safeServiceUrl"
|
||||
:href="safeServiceUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="m-primary-btn m-btn-link"
|
||||
>进入客服会话</a
|
||||
>
|
||||
<p v-else class="m-form-error">客服会话地址尚未配置</p>
|
||||
<p>首次连接会自动建立好友关系</p>
|
||||
</div>
|
||||
<div v-else-if="chatError" class="m-chat-empty">
|
||||
<n-icon :component="ChatbubbleEllipsesOutline" />
|
||||
<h2>连接失败</h2>
|
||||
<p class="m-form-error">{{ chatError }}</p>
|
||||
<button class="m-primary-btn" @click="connectChat">重新连接</button>
|
||||
</div>
|
||||
<div v-else ref="messageList" class="m-chat-messages">
|
||||
<div v-for="message in historicalMessages" :key="`history-${message.seq}-${message.from}`" :class="['m-chat-row', { 'is-mine': message.mine }]">
|
||||
<div class="m-chat-avatar" aria-hidden="true">
|
||||
<img v-if="!message.mine && safeImage(current?.avatar_url)" :src="safeImage(current?.avatar_url)" alt="" />
|
||||
<img v-else-if="message.mine && safeImage(me?.avatar_url)" :src="safeImage(me?.avatar_url)" alt="" />
|
||||
<span v-else-if="message.mine">👨🏻💼</span>
|
||||
<span v-else>👩🏻💼</span>
|
||||
</div>
|
||||
<div class="m-chat-message">
|
||||
<div :class="['m-chat-bubble', { 'is-media': message.type !== 'text' }]">
|
||||
<img v-if="message.type === 'image' && message.media_url" class="m-chat-media" :src="message.media_url" :alt="message.file_name || '聊天照片'" />
|
||||
<video v-else-if="message.type === 'video' && message.media_url" class="m-chat-media" :src="message.media_url" :poster="message.preview_url" controls playsinline></video>
|
||||
<p v-if="message.text">{{ message.text }}</p>
|
||||
</div>
|
||||
<small>
|
||||
<span v-if="message.mine && !message.sending && !message.failed">已发送 · </span>{{ messageTime(message.sent_at) }}
|
||||
<span v-if="message.sending"> · 发送中</span><span v-if="message.failed"> · 发送失败</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="historicalMessages.length" class="m-chat-history-divider"><span>以上是历史会话</span></div>
|
||||
<div class="m-chat-session-tip">[ {{ current?.name || '在线客服' }} ] 与您建立了会话</div>
|
||||
<div class="m-chat-system-row">
|
||||
<div class="m-chat-avatar m-chat-avatar--agent" aria-hidden="true">
|
||||
<img v-if="safeImage(current?.avatar_url)" :src="safeImage(current?.avatar_url)" alt="" />
|
||||
<span v-else>👩🏻💼</span>
|
||||
</div>
|
||||
<div class="m-chat-system-message">
|
||||
<small>{{ current?.name || '在线客服' }} · {{ messageDateTime(sessionStartedAt) }}</small>
|
||||
<p><b>【系统消息】</b>尊敬的用户您好,我是{{ current?.name || '在线客服' }},当前已为您接通在线客服,请问有什么可以帮您?</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="message in liveMessages" :key="`live-${message.seq}-${message.from}`" :class="['m-chat-row', { 'is-mine': message.mine }]">
|
||||
<div class="m-chat-avatar" aria-hidden="true">
|
||||
<img v-if="!message.mine && safeImage(current?.avatar_url)" :src="safeImage(current?.avatar_url)" alt="" />
|
||||
<img v-else-if="message.mine && safeImage(me?.avatar_url)" :src="safeImage(me?.avatar_url)" alt="" />
|
||||
<span v-else-if="message.mine">👨🏻💼</span>
|
||||
<span v-else>👩🏻💼</span>
|
||||
</div>
|
||||
<div class="m-chat-message">
|
||||
<div :class="['m-chat-bubble', { 'is-media': message.type !== 'text' }]">
|
||||
<img v-if="message.type === 'image' && message.media_url" class="m-chat-media" :src="message.media_url" :alt="message.file_name || '聊天照片'" />
|
||||
<video v-else-if="message.type === 'video' && message.media_url" class="m-chat-media" :src="message.media_url" :poster="message.preview_url" controls playsinline></video>
|
||||
<p v-if="message.text">{{ message.text }}</p>
|
||||
</div>
|
||||
<small>
|
||||
<span v-if="message.mine && !message.sending && !message.failed">已发送 · </span>{{ messageTime(message.sent_at) }}
|
||||
<span v-if="message.sending"> · 发送中</span><span v-if="message.failed"> · 发送失败</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-chat-composer" :class="{ 'is-expanded': toolsOpen }">
|
||||
<form class="m-chat-input" @submit.prevent="sendMessage">
|
||||
<input v-model="draft" :disabled="chatLoading || Boolean(chatError) || sending || mediaSending" maxlength="1000" placeholder="请输入消息" />
|
||||
<button
|
||||
v-if="draft.trim()"
|
||||
class="m-chat-send"
|
||||
type="submit"
|
||||
:disabled="chatLoading || Boolean(chatError) || sending || mediaSending"
|
||||
>{{ sending ? '发送中' : '发送' }}</button>
|
||||
<button v-else class="m-chat-tool" type="button" aria-label="更多" @click="toggleTools">
|
||||
<img :src="mobileAssets.chatMore" alt="" />
|
||||
</button>
|
||||
</form>
|
||||
<div v-if="mediaSending" class="m-chat-uploading"><progress max="100" :value="uploadProgress"></progress><b>正在上传 {{ uploadProgress }}%</b></div>
|
||||
<div v-if="toolsOpen && !emojiOpen" class="m-chat-tools">
|
||||
<button type="button" @click="emojiOpen = true"><n-icon :component="HappyOutline" /><span>表情</span></button>
|
||||
<button type="button" :disabled="mediaSending" @click="photoInput?.click()"><n-icon :component="ImageOutline" /><span>照片</span></button>
|
||||
<button type="button" :disabled="mediaSending" @click="videoInput?.click()"><n-icon :component="VideocamOutline" /><span>视频</span></button>
|
||||
<input ref="photoInput" class="m-chat-file-input" type="file" accept="image/jpeg,image/png,image/webp,image/gif" @change="sendMedia('image', $event)" />
|
||||
<input ref="videoInput" class="m-chat-file-input" type="file" accept="video/mp4,video/webm,video/quicktime" @change="sendMedia('video', $event)" />
|
||||
</div>
|
||||
<div v-if="toolsOpen && emojiOpen" class="m-chat-emoji-panel">
|
||||
<div class="m-chat-emoji-tabs" role="tablist" aria-label="表情分类">
|
||||
<button
|
||||
v-for="category in emojiCategories"
|
||||
:key="category.key"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeEmojiCategory === category.key"
|
||||
:class="{ 'is-active': activeEmojiCategory === category.key }"
|
||||
@click="activeEmojiCategory = category.key"
|
||||
>{{ category.label }}</button>
|
||||
</div>
|
||||
<div class="m-chat-emojis">
|
||||
<button v-for="emoji in visibleEmojis" :key="emoji" type="button" @click="insertEmoji(emoji)">{{ emoji }}</button>
|
||||
</div>
|
||||
<div v-if="!visibleEmojis.length" class="m-chat-emoji-empty">暂无最近使用的表情</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-chat-input"
|
||||
><button aria-label="语音"><img :src="mobileAssets.chatVoice" alt="" /></button
|
||||
><input disabled placeholder="请先连接客服会话" /><button aria-label="更多"
|
||||
><img :src="mobileAssets.chatMore" alt="" /></button
|
||||
></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { ChatbubbleEllipsesOutline, HeadsetOutline } from '@vicons/ionicons5';
|
||||
import {
|
||||
ChatbubbleEllipsesOutline,
|
||||
HappyOutline,
|
||||
HeadsetOutline,
|
||||
ImageOutline,
|
||||
VideocamOutline,
|
||||
} from '@vicons/ionicons5';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { errorText, request, safeImage } from '../api';
|
||||
import { deviceID, errorText, request, safeImage } from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
import { CustomerServiceChat, type ChatMessage, type CustomerServiceSession } from '../im';
|
||||
const route = useRoute();
|
||||
const isChat = computed(() => Boolean(route.params.id));
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const services = ref<any[]>([]);
|
||||
const me = ref<any>();
|
||||
const current = computed(
|
||||
() =>
|
||||
services.value.find((item) => String(item.id) === String(route.params.id)) ||
|
||||
services.value[0]
|
||||
);
|
||||
const safeServiceUrl = computed(() => {
|
||||
const value = current.value?.service_url;
|
||||
if (!value) return '';
|
||||
try {
|
||||
const u = new URL(value);
|
||||
return u.protocol === 'https:' ? u.href : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
const chatLoading = ref(false);
|
||||
const chatError = ref('');
|
||||
const messages = ref<ChatMessage[]>([]);
|
||||
const historyBoundarySeq = ref(0);
|
||||
const historicalMessages = computed(() => messages.value.filter((message) => message.seq <= historyBoundarySeq.value));
|
||||
const liveMessages = computed(() => messages.value.filter((message) => message.seq > historyBoundarySeq.value));
|
||||
const draft = ref('');
|
||||
const sending = ref(false);
|
||||
const mediaSending = ref(false);
|
||||
const uploadProgress = ref(0);
|
||||
const photoInput = ref<HTMLInputElement | null>(null);
|
||||
const videoInput = ref<HTMLInputElement | null>(null);
|
||||
const toolsOpen = ref(false);
|
||||
const emojiOpen = ref(false);
|
||||
type EmojiCategory = 'recent' | 'face' | 'gesture' | 'object';
|
||||
const emojiCategories: Array<{ key: EmojiCategory; label: string }> = [
|
||||
{ key: 'recent', label: '最近' },
|
||||
{ key: 'face', label: '表情' },
|
||||
{ key: 'gesture', label: '手势' },
|
||||
{ key: 'object', label: '物品' },
|
||||
];
|
||||
const emojiGroups: Record<Exclude<EmojiCategory, 'recent'>, string[]> = {
|
||||
face: [
|
||||
'😀', '😁', '😂', '🤣', '😊', '😍', '😘', '😜', '🤔', '😐', '😴', '😭',
|
||||
'😡', '🥳', '😱', '🤯', '🥰', '😇', '😮', '😉', '😆', '😅', '☺️', '😋',
|
||||
'😝', '🤗', '🤩', '🥺', '😢', '😤', '🤬', '🥵', '🥶', '😵', '🤢', '😷',
|
||||
'🤧', '😏', '😪', '🙄',
|
||||
],
|
||||
gesture: [
|
||||
'👍', '👎', '👌', '✌️', '🤞', '🤟', '🤘', '👏', '🙌', '👐', '🤲', '🙏',
|
||||
'👋', '🤝', '💪', '✍️', '☝️', '👆', '👇', '👉', '👈', '🫶', '💅', '🤙',
|
||||
],
|
||||
object: [
|
||||
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🌹', '🌸', '🎉', '🎁',
|
||||
'⭐', '✨', '🔥', '💯', '🎂', '🍰', '☕', '🍻', '⚽', '🏆', '🚀', '💡',
|
||||
],
|
||||
};
|
||||
const recentEmojis = ref<string[]>([]);
|
||||
const activeEmojiCategory = ref<EmojiCategory>('face');
|
||||
const visibleEmojis = computed(() =>
|
||||
activeEmojiCategory.value === 'recent' ? recentEmojis.value : emojiGroups[activeEmojiCategory.value]
|
||||
);
|
||||
const sessionStartedAt = ref(new Date().toISOString());
|
||||
const messageList = ref<HTMLElement | null>(null);
|
||||
let chat: CustomerServiceChat | null = null;
|
||||
let connectionVersion = 0;
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
services.value = await request<any[]>({ url: '/v1/customer-services' });
|
||||
const [serviceRows, profile] = await Promise.all([
|
||||
request<any[]>({ url: '/v1/customer-services' }),
|
||||
request<any>({ url: '/v1/me' }).catch(() => null),
|
||||
]);
|
||||
services.value = serviceRows;
|
||||
me.value = profile;
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
const message = errorText(err);
|
||||
error.value = message;
|
||||
if (isChat.value) chatError.value = message;
|
||||
services.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -105,5 +239,149 @@
|
||||
if (status === 'busy') return '忙碌';
|
||||
return '离线留言';
|
||||
}
|
||||
function disconnectChat() {
|
||||
connectionVersion += 1;
|
||||
chat?.disconnect();
|
||||
chat = null;
|
||||
messages.value = [];
|
||||
historyBoundarySeq.value = 0;
|
||||
chatLoading.value = false;
|
||||
chatError.value = '';
|
||||
draft.value = '';
|
||||
sending.value = false;
|
||||
mediaSending.value = false;
|
||||
uploadProgress.value = 0;
|
||||
toolsOpen.value = false;
|
||||
emojiOpen.value = false;
|
||||
}
|
||||
async function connectChat() {
|
||||
const serviceID = Number(route.params.id);
|
||||
disconnectChat();
|
||||
if (!serviceID) return;
|
||||
const version = connectionVersion;
|
||||
chatLoading.value = true;
|
||||
try {
|
||||
const session = await request<CustomerServiceSession>({
|
||||
url: `/v1/customer-services/${serviceID}/session`,
|
||||
method: 'POST',
|
||||
data: { device_id: deviceID(), platform: 'web', app_version: '1.0.0' },
|
||||
});
|
||||
if (version !== connectionVersion) return;
|
||||
const nextChat = new CustomerServiceChat(session, (rows) => {
|
||||
if (version !== connectionVersion) return;
|
||||
messages.value = rows;
|
||||
void scrollToBottom();
|
||||
});
|
||||
chat = nextChat;
|
||||
await nextChat.connect(session.token);
|
||||
if (version !== connectionVersion) {
|
||||
nextChat.disconnect();
|
||||
return;
|
||||
}
|
||||
// Tinode delivers cached topic history just after subscribe resolves.
|
||||
// Give that initial batch a brief window so it can be separated from the new session.
|
||||
await new Promise<void>((resolve) => window.setTimeout(resolve, 250));
|
||||
if (version !== connectionVersion) return;
|
||||
historyBoundarySeq.value = messages.value.reduce((max, message) => Math.max(max, message.seq), 0);
|
||||
sessionStartedAt.value = new Date().toISOString();
|
||||
} catch (err) {
|
||||
if (version !== connectionVersion) return;
|
||||
chat?.disconnect();
|
||||
chat = null;
|
||||
chatError.value = errorText(err);
|
||||
} finally {
|
||||
if (version === connectionVersion) chatLoading.value = false;
|
||||
}
|
||||
}
|
||||
async function sendMessage() {
|
||||
if (!chat || sending.value || !draft.value.trim()) return;
|
||||
const activeChat = chat;
|
||||
const version = connectionVersion;
|
||||
const value = draft.value;
|
||||
draft.value = '';
|
||||
toolsOpen.value = false;
|
||||
emojiOpen.value = false;
|
||||
sending.value = true;
|
||||
try {
|
||||
await activeChat.send(value);
|
||||
if (version !== connectionVersion) return;
|
||||
await scrollToBottom();
|
||||
} catch (err) {
|
||||
if (version !== connectionVersion) return;
|
||||
draft.value = value;
|
||||
chatError.value = errorText(err);
|
||||
} finally {
|
||||
if (version === connectionVersion) sending.value = false;
|
||||
}
|
||||
}
|
||||
async function sendMedia(type: 'image' | 'video', event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file || !chat || mediaSending.value) return;
|
||||
const activeChat = chat;
|
||||
const version = connectionVersion;
|
||||
const maxSize = type === 'image' ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
|
||||
if (!file.type.startsWith(`${type}/`)) {
|
||||
window.$message?.error(type === 'image' ? '请选择照片文件' : '请选择视频文件');
|
||||
return;
|
||||
}
|
||||
if (file.size > maxSize) {
|
||||
window.$message?.error(type === 'image' ? '照片不能超过 10MB' : '视频不能超过 50MB');
|
||||
return;
|
||||
}
|
||||
toolsOpen.value = false;
|
||||
emojiOpen.value = false;
|
||||
mediaSending.value = true;
|
||||
uploadProgress.value = 0;
|
||||
try {
|
||||
await activeChat.sendMedia(file, type, (progress) => {
|
||||
if (version !== connectionVersion) return;
|
||||
uploadProgress.value = Math.min(99, Math.round(progress * 100));
|
||||
});
|
||||
if (version !== connectionVersion) return;
|
||||
uploadProgress.value = 100;
|
||||
await scrollToBottom();
|
||||
} catch (err) {
|
||||
if (version !== connectionVersion) return;
|
||||
window.$message?.error(errorText(err));
|
||||
} finally {
|
||||
if (version === connectionVersion) {
|
||||
mediaSending.value = false;
|
||||
uploadProgress.value = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
function toggleTools() {
|
||||
toolsOpen.value = !toolsOpen.value;
|
||||
if (!toolsOpen.value) emojiOpen.value = false;
|
||||
}
|
||||
function insertEmoji(emoji: string) {
|
||||
draft.value += emoji;
|
||||
recentEmojis.value = [emoji, ...recentEmojis.value.filter((item) => item !== emoji)].slice(0, 24);
|
||||
}
|
||||
async function scrollToBottom() {
|
||||
await nextTick();
|
||||
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight;
|
||||
}
|
||||
function messageTime(value: string) {
|
||||
if (!value) return '';
|
||||
return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(value));
|
||||
}
|
||||
function messageDateTime(value: string) {
|
||||
if (!value) return '';
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(new Date(value));
|
||||
}
|
||||
watch(() => route.params.id, (serviceID) => {
|
||||
if (serviceID) void connectChat();
|
||||
else disconnectChat();
|
||||
}, { immediate: true, flush: 'sync' });
|
||||
onMounted(load);
|
||||
onBeforeUnmount(disconnectChat);
|
||||
</script>
|
||||
|
||||
@@ -41,36 +41,79 @@
|
||||
<span>加载中</span>
|
||||
</div>
|
||||
|
||||
<n-modal v-model:show="showConfirm">
|
||||
<form class="m-withdraw-confirm m-card" @submit.prevent="submitWithdrawal">
|
||||
<n-modal
|
||||
v-model:show="showConfirm"
|
||||
:auto-focus="false"
|
||||
:trap-focus="false"
|
||||
@after-enter="focusTradePassword"
|
||||
>
|
||||
<form v-if="showTradePasswordSetup" class="m-withdraw-confirm m-card" @submit.prevent="setTradePassword">
|
||||
<button class="m-withdraw-close" type="button" aria-label="关闭弹窗" :disabled="submitting" @click="closeConfirm">
|
||||
<n-icon :component="CloseOutline" />
|
||||
</button>
|
||||
<h2>设置支付密码</h2>
|
||||
<p class="m-withdraw-password-notice">设置完成后,请返回确认申请。</p>
|
||||
<label>
|
||||
<span>新支付密码</span>
|
||||
<input
|
||||
v-model="setupForm.newTradePassword"
|
||||
type="password"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
autocomplete="new-password"
|
||||
placeholder="请输入 6 位数字"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>登录密码确认</span>
|
||||
<input
|
||||
v-model="setupForm.loginPassword"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入登录密码"
|
||||
/>
|
||||
</label>
|
||||
<p v-if="setupError" class="m-form-error">{{ setupError }}</p>
|
||||
<button class="m-primary-btn" :disabled="submitting">{{ submitting ? '设置中...' : '确认设置' }}</button>
|
||||
<button class="m-outline-btn m-withdraw-back-btn" type="button" @click="showTradePasswordSetup = false">返回确认申请</button>
|
||||
</form>
|
||||
<form v-else class="m-withdraw-confirm m-card" @submit.prevent="submitWithdrawal">
|
||||
<button class="m-withdraw-close" type="button" aria-label="关闭弹窗" :disabled="submitting" @click="closeConfirm">
|
||||
<n-icon :component="CloseOutline" />
|
||||
</button>
|
||||
<h2>确认取出</h2>
|
||||
<div><span>申请金额</span><b>{{ money(quote?.request_amount) }}</b></div>
|
||||
<div><span>手续费</span><b>{{ money(quote?.fee_amount) }}</b></div>
|
||||
<div><span>预计到账</span><strong>{{ money(quote?.actual_amount) }}</strong></div>
|
||||
<label>
|
||||
<label v-if="hasTradePassword">
|
||||
<span>支付密码</span>
|
||||
<input
|
||||
ref="tradePasswordInput"
|
||||
v-model="form.tradePassword"
|
||||
type="password"
|
||||
name="trade-password"
|
||||
autocomplete="off"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="请输入 6 位支付密码"
|
||||
/>
|
||||
</label>
|
||||
<p v-else class="m-withdraw-password-notice">尚未设置支付密码,请先完成设置。</p>
|
||||
<button v-if="!hasTradePassword" class="m-outline-btn m-withdraw-setup-btn" type="button" @click="openTradePasswordSetup">去设置支付密码</button>
|
||||
<p v-if="actionError" class="m-form-error">{{ actionError }}</p>
|
||||
<button class="m-primary-btn" :disabled="submitting">确认申请</button>
|
||||
<button class="m-primary-btn" :disabled="submitting || !hasTradePassword">确认申请</button>
|
||||
</form>
|
||||
</n-modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ChevronForwardOutline } from '@vicons/ionicons5';
|
||||
import { ChevronForwardOutline, CloseOutline } from '@vicons/ionicons5';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { errorText, idempotencyKey, money, request } from '../api';
|
||||
import { MobileApiError, errorText, idempotencyKey, money, request } from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
|
||||
const router = useRouter();
|
||||
@@ -82,8 +125,13 @@
|
||||
const accounts = ref<any[]>([]);
|
||||
const quote = ref<any>();
|
||||
const showConfirm = ref(false);
|
||||
const showTradePasswordSetup = ref(false);
|
||||
const setupError = ref('');
|
||||
const withdrawalKey = ref('');
|
||||
const tradePasswordInput = ref<HTMLInputElement>();
|
||||
const form = reactive({ paymentAccountId: 0, amountYuan: '', tradePassword: '' });
|
||||
const setupForm = reactive({ newTradePassword: '', loginPassword: '' });
|
||||
const hasTradePassword = computed(() => me.value?.has_trade_password === true);
|
||||
const selectedAccount = computed(() =>
|
||||
accounts.value.find((item) => item.id === form.paymentAccountId)
|
||||
);
|
||||
@@ -101,6 +149,57 @@
|
||||
function openAccountPicker() {
|
||||
if (!selectedAccount.value) router.push('/app/profile/bank-card');
|
||||
}
|
||||
async function focusTradePassword() {
|
||||
await nextTick();
|
||||
if (hasTradePassword.value && !showTradePasswordSetup.value) tradePasswordInput.value?.focus();
|
||||
}
|
||||
function openTradePasswordSetup() {
|
||||
setupError.value = '';
|
||||
actionError.value = '';
|
||||
setupForm.newTradePassword = '';
|
||||
setupForm.loginPassword = '';
|
||||
showTradePasswordSetup.value = true;
|
||||
}
|
||||
function closeConfirm() {
|
||||
if (submitting.value) return;
|
||||
showConfirm.value = false;
|
||||
showTradePasswordSetup.value = false;
|
||||
actionError.value = '';
|
||||
setupError.value = '';
|
||||
form.tradePassword = '';
|
||||
setupForm.newTradePassword = '';
|
||||
setupForm.loginPassword = '';
|
||||
}
|
||||
async function setTradePassword() {
|
||||
if (submitting.value) return;
|
||||
setupError.value = '';
|
||||
if (!/^\d{6}$/.test(setupForm.newTradePassword) || setupForm.loginPassword.length < 6) {
|
||||
setupError.value = '请输入 6 位数字支付密码及登录密码';
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await request({
|
||||
url: '/v1/me/trade-password',
|
||||
method: 'PUT',
|
||||
data: {
|
||||
new_trade_password: setupForm.newTradePassword,
|
||||
login_password: setupForm.loginPassword,
|
||||
},
|
||||
});
|
||||
me.value = { ...me.value, has_trade_password: true };
|
||||
setupForm.newTradePassword = '';
|
||||
setupForm.loginPassword = '';
|
||||
form.tradePassword = '';
|
||||
showTradePasswordSetup.value = false;
|
||||
window.$message?.success('支付密码设置成功,请确认申请');
|
||||
await focusTradePassword();
|
||||
} catch (err) {
|
||||
setupError.value = errorText(err);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
loadError.value = '';
|
||||
@@ -138,6 +237,8 @@
|
||||
data: { payment_account_id: form.paymentAccountId, amount, currency: 'CNY' },
|
||||
});
|
||||
withdrawalKey.value = idempotencyKey();
|
||||
form.tradePassword = '';
|
||||
showTradePasswordSetup.value = false;
|
||||
showConfirm.value = true;
|
||||
} catch (err) {
|
||||
actionError.value = errorText(err);
|
||||
@@ -147,6 +248,10 @@
|
||||
}
|
||||
async function submitWithdrawal() {
|
||||
if (!quote.value || submitting.value) return;
|
||||
if (!hasTradePassword.value) {
|
||||
actionError.value = '请先设置支付密码';
|
||||
return;
|
||||
}
|
||||
if (!/^\d{6}$/.test(form.tradePassword)) {
|
||||
actionError.value = '请输入 6 位支付密码';
|
||||
return;
|
||||
@@ -170,6 +275,9 @@
|
||||
await router.replace('/app/profile/records?tab=withdrawal');
|
||||
} catch (err) {
|
||||
actionError.value = errorText(err);
|
||||
if (err instanceof MobileApiError && err.reason === 'TRADE_PASSWORD_NOT_SET') {
|
||||
me.value = { ...me.value, has_trade_password: false };
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
|
||||
+21
-1
@@ -86,7 +86,7 @@ const routes: RouteRecordRaw[] = [
|
||||
path: 'profile/bank-card',
|
||||
name: 'MobileBankCard',
|
||||
component: () => import('../mobile/views/FormView.vue'),
|
||||
meta: { title: '添加银行卡', form: 'bank' },
|
||||
meta: { title: '绑定信息', form: 'bank' },
|
||||
},
|
||||
{
|
||||
path: 'profile/withdrawal',
|
||||
@@ -187,6 +187,26 @@ const router = createRouter({
|
||||
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||
});
|
||||
|
||||
const staleChunkReloadKey = 'olons:stale-chunk-reload-at';
|
||||
const staleChunkErrorPattern =
|
||||
/Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module/i;
|
||||
|
||||
function reloadAfterStaleChunk(error?: unknown) {
|
||||
if (error && !staleChunkErrorPattern.test(String(error))) return;
|
||||
const now = Date.now();
|
||||
const lastReloadAt = Number(window.sessionStorage.getItem(staleChunkReloadKey) || 0);
|
||||
if (now - lastReloadAt < 10_000) return;
|
||||
window.sessionStorage.setItem(staleChunkReloadKey, String(now));
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
window.addEventListener('vite:preloadError', (event) => {
|
||||
event.preventDefault();
|
||||
reloadAfterStaleChunk();
|
||||
});
|
||||
|
||||
router.onError((error) => reloadAfterStaleChunk(error));
|
||||
|
||||
router.afterEach((route) => {
|
||||
document.title = route.meta.title ? `${String(route.meta.title)} - Olons` : 'Olons';
|
||||
});
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module 'tinode-sdk' {
|
||||
export const Drafty: any;
|
||||
export const Tinode: any;
|
||||
}
|
||||
@@ -15,6 +15,10 @@ export default defineConfig(({ mode }) => {
|
||||
target: env.VITE_API_TARGET || 'http://localhost:6061',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/uploads': {
|
||||
target: env.VITE_API_TARGET || 'http://localhost:6061',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
Reference in New Issue
Block a user