Compare commits

..
55 Commits
Author SHA1 Message Date
richjohnrambo dd729cb679 商城c端前端 2026-10-12 23:26:36 +08:00
richjohnrambo 8f3a97a54d 商城c端前端 2026-10-12 20:29:27 +08:00
richjohnrambo 2d411e1b44 排除前台编译文件 2026-10-12 16:18:15 +08:00
richjohnrambo 3f15082ebc feat: add integrated web frontend 2026-10-10 23:47:28 +08:00
richjohnrambo 81e3cf1d03 feat: migrate workbench services 2026-10-10 23:34:06 +08:00
richjohnrambo c13fb72849 fix: split workbench core mysql access 2026-10-07 14:38:56 +08:00
Your Name 8d32376de8 feat: split workbench core mysql access 2026-10-06 23:44:37 +08:00
richjohnrambo 71b49ddcf7 fix: preserve meeting note file metadata 2026-09-30 14:25:44 +08:00
richjohnrambo d2e70efd9d feat: async ai workbench tasks 2026-09-30 11:37:49 +08:00
richjohnrambo 47a10c93be fix: improve ai table file handling 2026-09-30 10:02:00 +08:00
richjohnrambo 1bcce3cdde fix: fallback on invalid ai table output 2026-09-29 17:37:57 +08:00
richjohnrambo 192ab342f6 fix: upload ai table exports to oss 2026-09-29 16:50:36 +08:00
richjohnrambo 9cb8c4d93a fix: generate ai table docx exports 2026-09-29 16:40:28 +08:00
richjohnrambo 37e89a6457 fix: persist ai task user ids consistently 2026-09-29 15:51:12 +08:00
richjohnrambo 97a43a6c96 fix: load ai config from manager 2026-09-29 15:07:27 +08:00
richjohnrambo a85393b2b8 fix: filter monthly reports by month range 2026-09-28 15:07:23 +08:00
richjohnrambo 75f811bb9f fix: filter daily reports by date 2026-09-28 14:34:08 +08:00
richjohnrambo 8773991015 feat: add workbench AI integration 2026-09-28 11:28:24 +08:00
richjohnrambo 5bf68727fa fix: cap mysql connection pool 2026-09-27 18:36:00 +08:00
richjohnrambo 4f69dc302b fix: read meeting unread from tenant notification shard 2026-09-24 17:21:33 +08:00
richjohnrambo bc3537bd9e fix: make tenant workbench initialization idempotent 2026-09-24 00:06:56 +08:00
richjohnrambo 86c5486cc4 feat: initialize tenant workbench in shared database 2026-09-23 17:04:01 +08:00
richjohnrambo c8413f82d9 fix: use shared workbench database for tenants 2026-09-23 11:21:05 +08:00
richjohnrambo 0a3a406089 fix: qualify tenant filter in report stats 2026-09-22 16:01:26 +08:00
richjohnrambo a39002b49d fix: scope workbench mysql queries by tenant 2026-09-22 15:02:09 +08:00
richjohnrambo 53accc3e3f fix: hydrate meeting note file uploader 2026-09-21 11:02:36 +08:00
richjohnrambo 06e3f0b489 fix: resolve meeting note files from oss urls 2026-09-18 16:29:14 +08:00
richjohnrambo ef2967b056 fix: hydrate meeting note file metadata 2026-09-18 16:26:06 +08:00
richjohnrambo dacac13f88 fix: return attendance checkin metadata as objects 2026-09-18 09:51:05 +08:00
richjohnrambo 5c805c7b09 feat: complete workbench split admin routes 2026-09-17 23:37:16 +08:00
richjohnrambo 7099a33cde fix: use workbench service name for manager config 2026-09-17 22:55:28 +08:00
richjohnrambo fe7d4ccb3e fix: preserve meeting note file metadata 2026-09-17 20:40:48 +08:00
richjohnrambo 9cfeb5f2c7 fix: route workbench requests to tenant database 2026-09-17 18:32:26 +08:00
richjohnrambo dd5986d57a fix: avoid build-time manager secret requirement 2026-09-17 17:49:07 +08:00
richjohnrambo ba0101297d fix: configure workbench manager connection 2026-09-17 17:35:16 +08:00
richjohnrambo f027764c47 fix: update uid encryption key fallback 2026-09-17 16:59:47 +08:00
richjohnrambo ba6fd7f1b6 fix: load uid key from manager config 2026-09-17 16:09:30 +08:00
richjohnrambo 228e3a54fd fix: align meeting note payloads 2026-09-17 13:38:36 +08:00
richjohnrambo fc246c3086 test: cover string user ids in workbench payloads 2026-09-16 18:05:26 +08:00
richjohnrambo 903a77f2c5 fix: keep workbench user ids compatible 2026-09-16 18:02:19 +08:00
richjohnrambo 5789b85663 fix: align internal workbench default secret 2026-09-16 16:05:39 +08:00
richjohnrambo b83390bccd docs: document workbench service dns 2026-09-16 15:41:32 +08:00
richjohnrambo 250a305fbf fix: configure internal workbench secret env 2026-09-16 15:21:28 +08:00
richjohnrambo 1bedf7e11c fix: fallback internal workbench secret 2026-09-16 14:28:42 +08:00
richjohnrambo 21c36abf58 feat: add internal workbench admin api 2026-09-16 14:03:51 +08:00
richjohnrambo cf08f7ceeb fix: port mailbox validation from im server 2026-09-15 13:31:45 +08:00
richjohnrambo 1d994c9302 fix: validate attendance checkin payload 2026-09-15 13:19:05 +08:00
richjohnrambo bdb704832f chore: enable kafka in local workbench config 2026-09-15 10:30:00 +08:00
richjohnrambo edb9a6b2bb fix: improve workbench kafka publish handling 2026-09-15 09:46:26 +08:00
richjohnrambo 989e365492 docs: add workbench migration handoff guide 2026-09-14 14:49:19 +08:00
richjohnrambo c1c11560de fix: update workbench test middleware config 2026-09-14 14:11:36 +08:00
richjohnrambo 8b5c5e3df5 fix: align workbench queries with current schema 2026-09-14 10:49:06 +08:00
Your NameandClaude Sonnet 5 4c964ee3ba ci: add deploy workflow for the k3s test cluster
im_workbench had no CI/CD before. Build+push under the mutable
:test tag the cluster overlay tracks, then roll the k3s deployment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 05:41:35 +08:00
richjohnrambo 982e71f093 feat: scaffold independent workbench service 2026-09-13 22:17:01 +08:00
richjohnrambo 482f9b80d9 Initial commit 2026-09-13 05:18:09 +00:00
67 changed files with 10225 additions and 2 deletions
+37
View File
@@ -0,0 +1,37 @@
name: Deploy im-workbench (k3s test cluster)
on:
workflow_dispatch:
inputs:
image_tag:
description: '镜像tag,默认为 test(与集群 kustomization 里的 newTag 一致)'
required: false
jobs:
deploy:
runs-on: deploy-host
steps:
- uses: actions/checkout@v4
- name: 确定镜像tag
id: vars
run: |
TAG="${{ github.event.inputs.image_tag }}"
[ -z "$TAG" ] && TAG=test
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: 登录镜像仓库
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.118im.com -u admin --password-stdin
- name: 构建并推送镜像
run: |
docker build -t git.118im.com/im_team/im-workbench:${{ steps.vars.outputs.tag }} .
docker push git.118im.com/im_team/im-workbench:${{ steps.vars.outputs.tag }}
- name: 部署到k3s测试集群
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
chmod 600 ~/.kube/config
kubectl rollout restart deployment/im-workbench -n workbench-system
kubectl rollout status deployment/im-workbench -n workbench-system --timeout=120s
+2 -1
View File
@@ -3,6 +3,7 @@
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
# #
# Binaries for programs and plugins # Binaries for programs and plugins
bin/
*.exe *.exe
*.exe~ *.exe~
*.dll *.dll
@@ -24,6 +25,7 @@ go.work.sum
# env file # env file
.env .env
.idea/
# ---> Node # ---> Node
# Logs # Logs
@@ -162,4 +164,3 @@ dist
.yarn/build-state.yml .yarn/build-state.yml
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .pnp.*
+30
View File
@@ -0,0 +1,30 @@
# ---- Stage 1: build the Vue frontend ----
FROM node:22-alpine AS web-builder
WORKDIR /web
RUN corepack enable
COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY web/ ./
RUN pnpm run build
# ---- Stage 2: build the Go backend ----
FROM golang:1.26 AS builder
WORKDIR /src
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
FROM alpine:3.22
WORKDIR /app
ENV WORKBENCH_CONFIG=/app/configs/workbench.test.json
COPY --from=builder /out/im_workbench /app/im_workbench
COPY configs /app/configs
COPY --from=web-builder /web/dist /app/web/dist
ENV WORKBENCH_FRONTEND_DIR=/app/web/dist
EXPOSE 6061
ENTRYPOINT ["/app/im_workbench"]
+16
View File
@@ -0,0 +1,16 @@
.PHONY: build test run web-install web-build
build:
go build -o bin/im_workbench ./cmd/workbench
test:
go test ./...
run:
WORKBENCH_CONFIG=configs/workbench.local.json go run ./cmd/workbench
web-install:
cd web && pnpm install --frozen-lockfile
web-build:
cd web && pnpm run build
+81 -1
View File
@@ -1,2 +1,82 @@
# olons_server # Workbench
移动端任务与账户平台后端。项目是独立应用,业务数据以 MySQL `olons_admin` 库中的 `hg_*` 表为唯一事实源。
项目现在采用前后端一体目录结构:`web/` 是参考 `im_manager` 组织的 Vue 3 + Vite 管理端,Go 服务在生产环境通过 `/admin/` 提供构建后的静态资源,`/v1/` 继续提供业务 API。
## 当前接口
- `POST /v1/auth/register`
- `POST /v1/auth/login`
- `POST /v1/auth/logout`
- `GET /v1/me`
- `PATCH /v1/me/avatar`
- `PUT /v1/me/password`
- `PUT /v1/me/trade-password`
- `GET /v1/home`
- `GET /v1/banners`
- `GET /v1/contents/{content_key}`
- `GET /v1/member-levels`
- `GET /v1/task-halls`
- `GET /v1/tasks/eligibility`
- `GET /v1/tasks/stats`
- `POST /v1/task-orders/claim`
- `GET /v1/task-orders`
- `GET /v1/task-orders/{order_id}`
- `POST /v1/task-orders/{order_id}/submissions`
- `GET /v1/manual-products`
- `GET /v1/manual-products/{product_id}`
- `GET /v1/payment-accounts`
- `POST /v1/payment-accounts`
- `POST /v1/withdrawal-orders/quote`
- `POST /v1/withdrawal-orders`
- `GET /v1/withdrawal-orders`
- `GET /v1/withdrawal-orders/{order_id}`
- `GET /healthz`
- `GET /metrics`
注册使用 `hg_app_member` 作为账户主档,并在同一事务中创建 `hg_app_member_profile` 和 `hg_wallet_account` 记录。登录按完整 `mobile` 查找会员,校验 bcrypt 密码后在 Redis 中创建独立会话。
## 启动配置
核心环境变量:
```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,多个以逗号分隔>'
```
`WORKBENCH_DATA_ENCRYPTION_KEY` 和 `WORKBENCH_LOOKUP_HMAC_KEY` 用于保护收款账户姓名、账号、银行卡预留手机号及取出快照等支付敏感信息。两个密钥必须分开生成、通过部署 Secret 注入,并与已有数据的加密配置保持一致;会员登录手机号直接保存在 `hg_app_member.mobile`。
前端静态目录可通过 `WORKBENCH_FRONTEND_DIR` 覆盖,默认值为 `web/dist`。本地开发可分别启动前后端:
```bash
make web-install
make web-build
make run
```
生产镜像使用 Docker 多阶段构建自动安装前端依赖、构建 `web/`,并将产物部署到 `/admin/`。
## 请求头
```http
X-API-Key: <api key>
X-Request-ID: <request id>
X-Device-ID: <optional device id>
```
默认租户通过 `WORKBENCH_DEFAULT_TENANT_ID` 配置,现有 `olons_admin` 数据使用 `tenant_id=0`。服务不接受客户端传入租户身份。
## 验证
```bash
make test
make build
```
前后端完整对接契约见 [`docs/移动端任务与账户平台前后端对接文档.md`](docs/移动端任务与账户平台前后端对接文档.md),已实现的注册登录简版契约见 [`docs/注册登录对接文档.md`](docs/注册登录对接文档.md)。
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git.118im.com/im_team/im_workbench/internal/app"
"git.118im.com/im_team/im_workbench/internal/config"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("load config: %v", err)
}
server, err := app.New(cfg)
if err != nil {
log.Fatalf("init app: %v", err)
}
defer server.Close()
httpServer := &http.Server{
Addr: cfg.Listen,
Handler: server.Handler(),
ReadHeaderTimeout: 5 * time.Second,
}
errCh := make(chan error, 1)
go func() {
log.Printf("workbench listening on %s", cfg.Listen)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
stopCh := make(chan os.Signal, 1)
signal.Notify(stopCh, syscall.SIGINT, syscall.SIGTERM)
select {
case err := <-errCh:
log.Fatalf("serve: %v", err)
case <-stopCh:
log.Printf("shutdown requested")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(ctx); err != nil {
log.Printf("shutdown error: %v", err)
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"listen": ":6061",
"frontend_dir": "web/dist",
"mysql": {
"dsn": "root:root@tcp(192.168.1.135:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local",
"max_open_conns": 15,
"max_idle_conns": 5
},
"redis": {
"addr": "127.0.0.1:6379",
"db": 0
},
"auth": {
"api_keys": [],
"session_key_pattern": "workbench:session:token:%s",
"session_ttl_seconds": 1209600,
"default_tenant_id": 0,
"data_encryption_key": "",
"lookup_hmac_key": "",
"allow_dev_header_auth": true,
"required_api_key": false
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"listen": ":6061",
"frontend_dir": "web/dist",
"mysql": {
"dsn": "",
"max_open_conns": 15,
"max_idle_conns": 5
},
"redis": {
"addr": "",
"db": 0
},
"auth": {
"api_keys": [],
"session_key_pattern": "workbench:session:token:%s",
"session_ttl_seconds": 1209600,
"default_tenant_id": 0,
"data_encryption_key": "",
"lookup_hmac_key": "",
"required_api_key": true
}
}
+101
View File
@@ -0,0 +1,101 @@
# 注册登录对接文档
接口公共前缀为 `/v1`,旧的应用名路径前缀不再提供兼容路由。
## 1 数据模型
账户以 `olons_admin.hg_app_member` 为主档:
- 手机号完整写入 `mobile`,同一租户内唯一,不再保存加密、哈希或脱敏辅助列。
- 登录密码使用 bcrypt 写入 `password_hash`。
- 推荐码通过上级会员的 `invite_code` 建立 `parent_id`、`level` 和 `tree`。
- 注册事务同时创建 `hg_app_member_profile` 和 CNY `hg_wallet_account`。
## 2 公共 Header
```http
X-API-Key: <api key>
X-Request-ID: <request id>
X-Device-ID: <optional device id>
```
## 3 注册
```http
POST /v1/auth/register
Content-Type: application/json
```
```json
{
"username": "alice01",
"phone": "13800138000",
"password": "Secret123",
"referral_code": "ABCD2345",
"privacy_accepted": true
}
```
`referral_code` 可选;传入时必须匹配当前租户下正常会员的 `invite_code`。
## 4 登录
```http
POST /v1/auth/login
Content-Type: application/json
```
```json
{
"phone": "13800138000",
"password": "Secret123"
}
```
界面的“记住密码”由客户端安全存储,不作为接口字段上传。
## 5 成功响应
```json
{
"code": 0,
"data": {
"member_id": 9,
"member_no": "MABCDEFGHIJKLMNOPQRST",
"username": "alice01",
"phone": "13800138000",
"invite_code": "EFGH6789",
"token": "...",
"token_type": "Bearer",
"expires_at": "2026-10-23T10:00:00Z"
},
"request_id": "uuid",
"server_time": "2026-10-09T10:00:00Z"
}
```
Token 是 32 字节随机会话凭证的 Base64URL 编码。Redis 只使用 Token SHA-256 摘要作为 Key,不存储原始 Token。
## 6 退出登录
```http
POST /v1/auth/logout
Authorization: Bearer <token>
```
退出成功后删除当前 Redis 会话,`data` 为 `null`。缺少、格式错误或已失效的 Token 返回 HTTP 401 和 `AUTH_REQUIRED`。
## 7 错误 reason
| reason | HTTP | 说明 |
| --- | --- | --- |
| `USERNAME_INVALID_FORMAT` | 400 | 账户名格式错误 |
| `PHONE_INVALID_FORMAT` | 400 | 手机号格式错误 |
| `PASSWORD_INVALID_FORMAT` | 400 | 密码格式错误 |
| `REFERRAL_CODE_INVALID` | 400 | 推荐码无效 |
| `PRIVACY_POLICY_NOT_ACCEPTED` | 400 | 未同意隐私政策 |
| `ACCOUNT_ALREADY_REGISTERED` | 409 | 账户名或手机号已注册 |
| `INVALID_CREDENTIALS` | 401 | 手机号或密码错误 |
| `AUTH_REQUIRED` | 401 | 缺少或无效的登录会话 |
| `RATE_LIMITED` | 429 | 15 分钟内登录失败次数过多 |
| `SERVICE_UNAVAILABLE` | 503 | Redis 或敏感数据密钥未正确配置 |
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.
@@ -0,0 +1,368 @@
# 移动端任务与账户平台需求文档
**参考站点功能拆解与重构建议**
目标:在不复制品牌素材、敏感数据与高风险业务表述的前提下,复刻参考站点的信息架构和交互骨架,并形成可开发、可验收、可审计的产品需求基线。
主要读者:产品负责人、交互设计师、前端开发、后端开发、测试、风控与运营。
观察日期:2026-10-07 参考环境:移动端网页 文档版本:V1.0
结论:参考站点由账户、任务订单、余额台账、储入取出、会员等级、内容展示和在线客服七个域构成。建议复刻页面结构与业务闭环,但重写资金、隐私、安全和合规机制。
# 1 文档范围与方法
本需求基于登录后的页面逐项浏览、按钮与导航行为检查、页面状态观察,以及浏览器中可见网络请求和前端资源分析。本文把结论分为三类:实测行为、前端可见实现、建议需求。敏感手机号、邀请码、银行卡等信息已脱敏,不作为原型内容。
## 1.1 已覆盖范围
- 登录页现状、注册页字段、会话与路由保护。
- 首页、荣誉榜、会员等级、平台简介、规则说明、代理合作、公司资质。
- 订单记录筛选、领取中心、订单资金变化、手工商城。
- 储入、取出、收款信息绑定、储入记录、取出记录、账户明细。
- 个人信息、头像上传、登录密码、交易密码、通知记录、在线客服。
- 可观察接口、响应结构、关键数据字段与前端安全实现。
## 1.2 未执行操作
为避免生成真实订单、变更资金、上传个人文件、修改密码或退出当前会话,本文未点击自动领取、订单提交、储入提交、取出确认、收款信息提交、头像上传、密码提交和退出登录。对应需求由页面字段、前端接口定义与上下游数据状态推导,并标记为待联调确认。
# 2 产品定位与角色
产品可定位为移动端会员任务与佣金账户平台。用户通过注册、储入余额、领取任务订单、完成任务、获得本金返还与佣金,并可查看台账或发起取出。平台通过等级、任务数量和费率管理用户权益,通过客服处理储入、异常订单和账户问题。
| **角色** | **主要目标** | **关键权限** |
| --- | --- | --- |
| 游客 | 了解平台并注册 | 登录、注册、查看隐私政策 |
| 会员 | 领取任务并查看收益 | 首页、任务、订单、余额、客服、个人设置 |
| 高级会员 | 按等级获得更多任务和更高费率 | 等级权益、额度与次数受配置控制 |
| 客服 | 处理储入、取出、异常任务 | 会话、工单、用户与交易查询 |
| 运营 | 管理内容、等级、任务和公告 | 配置、发布、审核、数据看板 |
| 财务与风控 | 审核资金和异常行为 | 充值审核、提现审核、台账、风控规则 |
| 管理员 | 维护权限与系统参数 | RBAC、审计日志、系统配置 |
# 3 信息架构与页面路由
| **一级模块** | **页面或路由** | **核心内容** | **入口** |
| --- | --- | --- | --- |
| 认证 | /login /register | 手机号登录、账户名、推荐码、验证码 | 未登录访问 |
| 首页 | /home | 活动图、快捷入口、余额、荣誉榜、会员等级、公司介绍 | 底部首页 |
| 任务 | /receive /orderDetail | 领取、等待、任务详情、提交完成 | 底部领取 |
| 记录 | 记录页 | 全部、待处理、已完成订单 | 底部记录 |
| 手工 | /mall | 手工任务卡片并引导客服 | 首页快捷入口 |
| 资金 | /recharge /withdrawal | 储入、取出、余额、交易密码 | 首页与个人中心 |
| 账户 | /mine /personalInfo | 用户资料、信用分、邀请码、设置 | 底部我的 |
| 明细 | /depositRecord /withdrawalRecord /bill | 储入、取出、余额台账 | 我的 |
| 客服 | /customerService /iframeCustom | 客服列表与第三方会话 | 底部客服 |
| 内容 | /html/{id} | 平台简介、规则、代理、资质 | 首页公司简介 |
## 3.1 建议导航原则
- 底部五栏固定为首页、记录、领取、客服、我的;详情页隐藏底部栏并保留明确返回。
- 资金页面必须显示币种、可用余额、冻结余额、预计到账和手续费,不使用含义不明的“人民币编码”。
- 所有金额变动都能跳转至同一业务单据,并在台账中保持可解释、可追溯。
- 客服 iframe 需明确第三方来源、隐私边界和会话状态,失败时提供备用联系方式。
# 4 核心业务闭环
## 4.1 任务订单闭环
1. 用户进入领取中心,系统校验登录态、营业时间、会员等级、最低可用余额、未完成任务与风控状态。
1. 用户点击领取后,系统创建或匹配一笔任务订单,返回任务编号、商品、金额、佣金、截止时间与状态。
1. 任务确认时,台账先记录交易扣款;完成后记录本金返还和佣金入账。参考数据中同一业务单号出现 -298、+298、+14.9 三条流水。
1. 订单必须支持待领取、匹配中、待处理、处理中、已完成、超时、冻结、取消与失败,并记录每次状态变化。
1. 任何补差额、冻结或信用分变化都必须有明确原因、复核入口和申诉渠道;不得依赖口头客服指令。
## 4.2 储入与取出闭环
储入页面按等级展示金额、赠送额、每日单数和佣金比例,用户填写金额与姓名后提交。参考站点还要求联系客服获取收款方式。新系统应改为平台内生成可核验的支付订单,禁止客服通过聊天发送临时个人账户。
取出前需绑定姓名、银行名、银行账户与收款码,随后输入金额和交易密码。新系统应只保留与实际支付通道必要的字段,敏感数据加密存储,收款码上传须做病毒扫描、内容校验与访问控制。
## 4.3 会员与代理闭环
参考站点设置普通、白银、黄金、铂金、钻石、星耀、至尊七档,充值门槛、赠送额、每日单数和佣金比例逐级上升,并存在三级推荐奖励。建议将等级规则做成版本化配置,禁止前端写死;代理奖励必须在合规评估通过后再启用。
# 5 页面级功能需求
## 5.1 登录与注册
| **页面** | **字段与控件** | **校验与行为** | **验收要点** |
| --- | --- | --- | --- |
| 登录 | 手机号、密码、记住密码、登录、去注册、隐私政策 | 手机号格式;密码非空;错误次数限制;登录成功进入首页 | 不在本地明文保存密码;支持会话过期提示 |
| 注册 | 账户名、11 位手机号、密码、推荐码、图片验证码、隐私政策 | 账户名必填;手机号唯一;验证码校验;推荐码可选或按运营配置 | 提交前明确同意协议;防批量注册;注册成功自动登录或返回登录 |
| 会话 | Authorization 令牌 | 401 清除会话并返回登录;403 显示可解释错误 | 支持刷新、撤销、设备管理与退出所有设备 |
## 5.2 首页
- 展示轮播图或单图运营位,支持后台排序、上下线、跳转类型和有效期。
- 快捷入口为领取、储入、取出、手工;按后台开关显示,关闭时给出原因而不是静默失效。
- 账户卡展示昵称、可用余额与币种;点击可进入账户明细。
- 荣誉榜为脱敏昵称、积分和结果状态,要求数据真实性标识、刷新频率与隐私策略。
- 会员等级展示当前等级、费率和下一等级条件;等级图不得只是一张不可访问的图片。
- 公司简介包括平台简介、规则说明、代理合作和公司资质,内容由 CMS 管理并保留版本。
## 5.3 记录与订单详情
记录页显示可用资产,提供全部、待处理、已完成三种筛选。列表项包括创建时间、商品图、商品名、商品价格、佣金与状态。分页默认 10 条,支持下拉刷新、空状态、加载失败重试和详情跳转。
实测发现先切换待处理、再切换已完成时曾短暂出现空列表,而重新加载全部后可见已完成记录。验收时需覆盖筛选参数、缓存状态和并发请求覆盖问题。
| **状态** | **用户可见文案** | **允许操作** | **系统约束** |
| --- | --- | --- | --- |
| 匹配中 | 正在匹配任务 | 取消等待 | 只允许一个匹配请求 |
| 待处理 | 请在截止时间前完成 | 查看详情、提交 | 倒计时与服务端时间一致 |
| 处理中 | 已提交,等待确认 | 查看详情、联系客服 | 防重复提交 |
| 已完成 | 本金与佣金已入账 | 查看流水 | 三笔或组合流水必须可核对 |
| 超时或冻结 | 显示原因与处理路径 | 申诉、联系客服 | 需要人工复核和审计 |
| 失败或取消 | 显示失败原因 | 重新领取 | 不得产生不一致余额 |
## 5.4 领取中心
领取中心包含会员等级、佣金费率、主领取按钮、匹配进度、今日战果和规则说明。点击主按钮前需完成资格预检;按钮点击后立即进入不可重复提交的 loading 状态;匹配成功跳转订单详情,失败显示可恢复原因。
- 预检项:营业时间、账户状态、信用或风控状态、最低余额、当日次数、未完成订单、等级商品范围。
- 统计项:总资产、昨日佣金、今日佣金、今日领取数;所有统计口径需要后台统一定义。
- 规则文案需要 CMS 管理,禁止把风控与资金规则硬编码在页面。
## 5.5 手工商城
页面以双列卡片展示图片、任务名称、份数、工资与已领取数;点击卡片实测直接进入客服页。建议增加任务详情、适用人群、交付方式、预计周期、库存、审核标准和风险提示,再由用户主动发起咨询。
参考站点一条商品名称中可见脚本标签文本,说明内容过滤不足。新系统必须对运营内容做 HTML 转义、富文本白名单、图片域名白名单和持久型 XSS 检测。
## 5.6 客服
客服页展示多个客服入口与工作时间,点击后在站内 iframe 打开第三方聊天。聊天页含历史会话、系统消息、文本框、图片与视频上传。需求上应增加服务状态、排队人数、会话编号、隐私声明、附件大小与类型限制、敏感信息提醒和会话评价。
## 5.7 我的与个人信息
| **功能** | **需求** | **边界或风险** |
| --- | --- | --- |
| 资料概览 | 头像、昵称、邀请码、会员等级、信用状态、余额 | 手机号等敏感字段默认脱敏 |
| 储入与取出 | 提供余额入口与状态提示 | 资金操作必须二次确认与风控 |
| 收款信息 | 姓名、银行、账户、可选支付通道 | 加密、最小化收集、修改留痕 |
| 头像 | 上传、裁剪、预览、替换 | 文件扫描、大小和格式校验 |
| 密码 | 旧密码、新密码、确认密码 | 强度、频控、成功后令牌失效 |
| 交易密码 | 独立于登录密码 | 不得用无盐 MD5;失败次数锁定 |
| 通知 | 公告列表、未读、已读、详情 | 重要规则变更需留存阅读证据 |
| 退出 | 清除令牌和本地用户数据 | 保留确认,避免误触 |
## 5.8 储入
页面展示等级规则表和金额、姓名输入框。参考规则为普通 500、白银 1888、黄金 10000、铂金 30000、钻石 50000、星耀 88888、至尊 188888;费率从 1.8% 到 5%。规则说明和表格存在个别口径差异,必须以后台版本化配置为唯一事实源。
- 提交前展示收款主体、订单号、有效期、金额、手续费和风险提示。
- 储入订单状态:待支付、待审核、成功、失败、过期、取消;失败必须提供原因。
- 若需要上传付款凭证,必须经过用户明确选择,并限制为图片或 PDF。
## 5.9 取出与收款绑定
取出页面展示余额、金额、已绑定收款信息摘要和交易密码。若未绑定,弹窗提示并引导至绑定页。绑定页实测字段为姓名、银行名、银行账户、人民币编码、支付宝收款码和微信收款码。建议删除含义不清或非必要字段,并根据支付渠道动态展示。
- 金额校验:大于 0、不超过可用余额、符合单笔最小值和最大值,并显示手续费与到账金额。
- 状态:待审核、审核中、处理中、成功、失败、驳回、取消;用户可查看原因和预计时间。
- 交易密码只在提交时使用;服务端采用强哈希校验并配合频率限制、设备风控和二次验证。
## 5.10 账户明细与记录
账户明细需按业务单号聚合展示变动类型、变动金额、变动前余额、变动后余额、时间、状态和关联单据。储入记录显示订单号、状态、金额、创建时间;取出记录字段同构,并增加手续费、到账金额与失败原因。
# 6 数据模型与状态机
| **实体** | **关键字段** | **说明** |
| --- | --- | --- |
| User | id, username, mobile, avatar, status, inviteCode | 用户主档,手机号单独加密与索引 |
| Membership | levelId, rate, dailyLimit, withdrawLimits, effectiveAt | 版本化等级权益 |
| Wallet | available, frozen, currency, version | 使用乐观锁或账户序列保证一致性 |
| LedgerEntry | bizNo, type, direction, amount, before, after | 不可变台账,支持幂等与冲正 |
| TaskOrder | orderNo, productId, amount, commission, status, deadline | 任务生命周期 |
| DepositOrder | depositNo, amount, channel, status, proofUrl | 储入生命周期 |
| WithdrawalOrder | withdrawNo, amount, fee, netAmount, status | 取出生命周期 |
| PayoutProfile | realName, bankName, maskedAccount, qrAssetIds | 敏感字段加密与脱敏 |
| Content | type, title, body, version, status, publishedAt | 公告、规则与公司资料 |
| SupportSession | sessionNo, provider, status, openedAt, closedAt | 第三方客服映射 |
| AuditLog | actor, action, target, before, after, ip, device | 资金与权限操作强制留痕 |
## 6.1 幂等与一致性
- 领取、提交订单、储入回调、取出申请和密码修改都必须使用幂等键。
- 钱包余额只能由服务端台账事务修改;前端显示值不参与结算。
- 订单状态转换由服务端状态机控制,不允许客户端任意设置。
- 重复回调、网络重试和并发点击不得重复扣款、返款或发佣金。
- 所有涉及金额的接口返回业务单号和最新余额版本。
# 7 接口需求
下表分为实测请求与前端资源中可见接口。实测接口在浏览器中返回 HTTP 200,响应外层为 Base64 字符串,解码后为 JSON,统一包含 success、code、data、msg。建议新系统直接使用标准 JSON,不把 Base64 当作安全措施。
| **方法** | **接口** | **用途** | **主要请求或返回字段** | **证据** |
| --- | --- | --- | --- | --- |
| POST | /wap-api/doLogin | 登录 | username, password;token, users | 前端实现与已发生请求 |
| POST | /wap-api/doRegister | 注册 | nickName, username, password, inviteCode, code, uuid | 前端实现 |
| GET | /wap-api/captchaImage | 图片验证码 | uuid, img, captchaBol | 前端实现 |
| GET | /wap-api/profile | 用户概要 | tel, username, headpic, inviteCode, level, levelRate, balance, xyf | 实测 |
| GET | /wap-api/banner/list | 首页横幅 | 图片地址数组 | 实测 |
| GET | /wap-api/home | 首页开关与内容编号 | SHOP_STATUS, TASK, POP_MESSAGE, 内容 ID | 实测 |
| GET | /wap-api/user/data/home | 荣誉榜 | name, avatar, balance | 实测 |
| GET | /wap-api/ctrl/level-list | 等级配置 | name, orderNum, num, giveNum, bili, 提现限制 | 实测 |
| GET | /wap-api/support | 客服列表 | qq, url, workTime, 分页 | 实测 |
| GET | /wap-api/convey/page | 订单分页 | pageNo, pageSize, status;订单与商品字段 | 实测 |
| POST | /wap-api/convey/order | 领取或匹配订单 | 无显式参数;返回订单 | 前端实现,未执行 |
| POST | /wap-api/convey/order/submit/{id} | 提交任务 | 订单 id | 前端实现,未执行 |
| GET | /wap-api/convey/count | 领取统计 | balance, countToday, countNumToday, countNumYesterday, content | 实测 |
| GET | /wap-api/convey/unpay | 未完成订单检查 | djs 等状态 | 实测 |
| GET | /wap-api/product/page | 手工商品分页 | 分页条件;商品卡片 | 前端实现 |
| GET | /wap-api/platform-config/trade-wait-time | 任务等待时间 | 秒数或分钟数 | 实测 |
| GET | /wap-api/bank/current/info | 当前收款信息 | 收款资料摘要 | 前端实现 |
| POST | /wap-api/bank/save | 保存收款信息 | 姓名、银行、账户、收款码 | 前端实现,未执行 |
| POST | /wap-api/recharge/recharge | 创建储入申请 | amount, name 等 | 前端实现,未执行 |
| GET | /wap-api/recharge/page | 储入记录分页 | 分页与状态 | 前端实现 |
| POST | /wap-api/deposit/do_deposit | 创建取出申请 | amount, paypassword 等 | 前端实现,未执行 |
| GET | /wap-api/deposit/page | 取出记录分页 | 分页与状态 | 前端实现 |
| GET | /wap-api/balance-log/page | 账户流水 | 分页;类型、金额、业务单号 | 前端实现与页面实测 |
| POST | /wap-api/users/setPwd | 修改登录密码 | oldPwd, newPwd | 前端实现,未执行 |
| POST | /wap-api/users/setPwd2 | 修改交易密码 | oldPwd, newPwd | 前端实现,未执行 |
| POST | /wap-api/local/upload | 文件上传 | multipart file | 前端实现,未执行 |
| GET/POST | /wap-api/user-tc/select /read/{id} /page | 弹窗通知 | 公告、已读、分页 | 实测与前端实现 |
## 7.1 新系统接口规范
- 统一使用 HTTPS、JSON、明确的 HTTP 状态码、traceId、错误码和可本地化 message。
- Authorization 使用短期访问令牌与可撤销刷新令牌;敏感操作增加一次性挑战或二次验证。
- 列表接口统一 page、pageSize、sort、status,返回 items、total、hasNext。
- 金额使用最小货币单位整数或高精度 Decimal,禁止浮点结算。
- 上传接口先申请受限上传凭证,服务端扫描后再绑定业务。
- 所有写接口要求 Idempotency-Key、设备信息与审计上下文。
# 8 管理后台需求
| **模块** | **主要能力** | **审批与审计** |
| --- | --- | --- |
| 用户管理 | 查询、状态、等级、信用、设备、邀请关系 | 冻结与解冻需理由和双人复核 |
| 等级配置 | 门槛、赠送、次数、费率、提现限制、商品范围 | 版本化发布,可回滚 |
| 任务商品 | 商品、图片、金额、佣金、库存、上下线 | 内容过滤和审核流 |
| 任务订单 | 状态、倒计时、资金流水、异常处理 | 人工改状态必须双人审批 |
| 储入管理 | 支付订单、回调、凭证、审核、对账 | 每日对账与差错处理 |
| 取出管理 | 审核、通道、手续费、失败重试 | 大额与异常规则 |
| 内容管理 | 横幅、规则、简介、资质、公告 | 预览、定时、版本与发布人 |
| 客服管理 | 客服入口、工作时间、会话映射、工单 | 第三方访问最小权限 |
| 风控 | 设备、IP、频次、关联账户、异常金额 | 命中规则可解释并可申诉 |
| 权限审计 | RBAC、操作日志、导出日志 | 关键权限定期复核 |
# 9 非功能需求
## 9.1 安全
- 密码使用 Argon2id 或 bcrypt 加盐存储。参考站点在浏览器端对密码做 MD5,这不能替代服务端强哈希。
- 输入与富文本做上下文相关转义;运营内容采用白名单;CSP 禁止任意外部脚本。
- 银行卡和手机号加密存储,日志与埋点禁止记录完整值;展示统一脱敏。
- 接口启用 CSRF 或同站策略、速率限制、风控挑战、签名回调和重放保护。
- 第三方客服 iframe 设置最小 sandbox 权限、allowlist 域名和清晰的数据传输告知。
- 上传文件限制 MIME、扩展名、体积与像素,执行恶意内容扫描,使用隔离存储和短期访问 URL。
## 9.2 性能与可用性
- 首屏 LCP 目标小于 2.5 秒,核心 API P95 小于 800 毫秒,写操作 P95 小于 1.5 秒。
- 按钮防抖、请求取消、重试与离线提示;长列表分页和图片懒加载。
- 资金写入接口可用性目标 99.95%,客服与内容接口目标 99.9%。
- 客户端时间仅用于展示,截止时间与状态以服务端为准。
## 9.3 可访问性与国际化
按钮和导航必须有语义标签与至少 44×44 像素触控区域;颜色不作为唯一状态标识;支持字体放大。简体、繁体和术语需统一,避免同一页面混用“储入/充值”“取出/提现”“领取/抢单”。
# 10 合规与业务风险
参考站点涉及先储入、任务扣款、返本付佣、推荐奖励、信用分和提现限制。此类模式在不同司法辖区可能触及支付、消费者保护、反洗钱、网络营销或其他监管要求。上线前需由法务和持牌支付机构评估,不应直接复制其资金规则、免责条款或客服收款模式。
| **风险** | **参考站点表现** | **新系统要求** | **优先级** |
| --- | --- | --- | --- |
| 资金真实性 | 客服提供收款方式,失败责任不清 | 使用合规支付通道、订单化收款与自动对账 | P0 |
| 诱导与不透明 | 高等级对应更高储入和收益 | 明确费用、风险、无保证收益,禁止误导 | P0 |
| 推荐奖励 | 三级佣金 16% 8% 4% | 法务审查后决定是否启用 | P0 |
| 账户冻结 | 规则含冻结、补差额、信用分 | 冻结需合法依据、通知、申诉和复核 | P0 |
| 内容安全 | 商品名出现脚本标签文本 | CMS 白名单与持久型 XSS 防护 | P0 |
| 密码安全 | 前端无盐 MD5 | 服务端强哈希、MFA、频控 | P0 |
| 隐私 | 收集银行账户和收款码 | 最小化、加密、授权、删除与访问审计 | P0 |
| 数据一致性 | 筛选短暂空列表、规则口径差异 | 单一配置源、请求竞态处理、自动化测试 | P1 |
# 11 埋点与运营指标
| **事件** | **触发时机** | **关键属性** |
| --- | --- | --- |
| auth_login_result | 登录成功或失败 | result, errorCode, deviceRisk |
| home_shortcut_click | 点击首页快捷入口 | target, userLevel |
| order_claim_click | 点击领取 | eligibilityResult, balanceBucket, level |
| order_state_change | 订单状态变化 | orderNo, from, to, reason |
| deposit_submit | 提交储入申请 | amountBucket, channel, result |
| withdraw_submit | 提交取出申请 | amountBucket, fee, result |
| support_open | 打开客服会话 | provider, entry, workStatus |
| content_view | 查看规则或公告 | contentId, version, readDuration |
| security_challenge | 触发安全验证 | scene, result, ruleId |
核心指标包括注册转化、首个任务完成率、领取成功率、订单超时率、储入成功率、取出成功率与处理时长、客服转人工率、异常账户率、账务差错率和投诉率。涉及金额与身份的数据只做区间化或匿名化统计。
# 12 验收标准
1. 未登录用户只能访问登录、注册和公开协议;受保护路由统一拦截。
2. 同一用户重复点击领取或提交订单,不产生重复业务单和重复资金流水。
3. 订单完成后,订单状态、钱包余额、三类流水和统计数据在一次事务或可验证最终一致性中完成。
4. 储入与取出所有状态均可在记录页查询,失败和驳回有明确原因。
5. 等级规则由后台发布后前台一致生效,表格、领取校验与提现限制不存在口径差异。
6. 手机号、银行卡、邀请码和交易密码不出现在客户端日志、服务端普通日志或分析平台。
7. 富文本、商品名、公告和客服消息中的脚本或危险 HTML 不能执行。
8. 接口 401、403、429、5xx、超时和断网场景均有清晰恢复路径。
9. 移动端 320 至 430 像素宽度可用,底部导航不遮挡内容,键盘弹出时表单按钮可达。
10. 关键资金和权限操作在后台可按用户、业务单号和时间完整审计。
# 13 交付优先级与里程碑
| **阶段** | **范围** | **退出条件** |
| --- | --- | --- |
| MVP 认证与账户 | 登录注册、资料、钱包、台账、内容、客服 | 权限、安全、审计基线通过 |
| MVP 任务闭环 | 任务列表、领取、详情、提交、状态机、佣金 | 幂等和账务一致性测试通过 |
| 资金闭环 | 储入、取出、收款绑定、支付通道、对账 | 支付与法务合规评审通过 |
| 运营能力 | 等级、公告、横幅、商品、通知、后台 | 配置版本和发布审核可用 |
| 增长与优化 | 邀请关系、数据看板、体验优化 | 合规批准且核心指标稳定 |
# 14 低保真原型
以下原型根据实测页面重新绘制,用于确认页面层级、组件位置和交互入口,不复制原站品牌素材、头像或敏感数据。视觉设计阶段应重新建立色彩、字体、图标和组件规范。
![低保真原型图 1](移动端任务与账户平台需求文档.assets/figure-01.png)
图 1 参考站点功能复刻原型
![低保真原型图 2](移动端任务与账户平台需求文档.assets/figure-02.png)
图 2 参考站点功能复刻原型
![低保真原型图 3](移动端任务与账户平台需求文档.assets/figure-03.png)
图 3 参考站点功能复刻原型
![低保真原型图 4](移动端任务与账户平台需求文档.assets/figure-04.png)
图 4 参考站点功能复刻原型
# 15 待确认事项
- 目标产品是否真实涉及用户资金储入与取出,还是仅做演示积分。
- 任务来源、商品真实性、结算主体和佣金承担方。
- 目标市场与适用法律、支付通道和实名要求。
- 是否保留会员等级、推荐奖励、信用分和冻结机制。
- 客服使用自建系统还是第三方服务,允许传输哪些用户数据。
- 管理后台的组织结构、审批层级和财务对账责任人。
- 品牌名、语言版本、视觉方向与是否需要 App 壳或 PWA。
在上述事项明确前,可以先完成无资金风险的认证、内容、任务展示、客服、账户明细和后台配置原型;资金接口只做沙箱与模拟数据。
+26
View File
@@ -0,0 +1,26 @@
module git.118im.com/im_team/im_workbench
go 1.26.0
require (
github.com/go-sql-driver/mysql v1.10.0
github.com/jmoiron/sqlx v1.4.0
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.12.1
golang.org/x/crypto v0.53.0
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
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/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
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.46.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
)
+68
View File
@@ -0,0 +1,68 @@
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/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/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=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg=
github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+123
View File
@@ -0,0 +1,123 @@
package accounts
import (
"context"
"errors"
"testing"
"git.118im.com/im_team/im_workbench/internal/store"
"golang.org/x/crypto/bcrypt"
)
type profileRepository struct {
overview *store.MemberOverview
security *store.AccountSecurity
loginPasswordHash string
tradePasswordHash string
}
func (r *profileRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
return nil, errors.New("not implemented")
}
func (r *profileRepository) AccountByMobile(context.Context, int64, string) (*store.Account, error) {
return nil, errors.New("not implemented")
}
func (r *profileRepository) RecordLogin(context.Context, int64, int64) error { return nil }
func (r *profileRepository) MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error) {
return r.overview, nil
}
func (r *profileRepository) UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error) {
return &store.Avatar{AttachmentID: 12, URL: "https://cdn.example/avatar.png"}, nil
}
func (r *profileRepository) AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) {
return r.security, nil
}
func (r *profileRepository) UpdateLoginPassword(_ context.Context, _, _ int64, _, newHash string) error {
r.loginPasswordHash = newHash
return nil
}
func (r *profileRepository) UpdateTradePassword(_ context.Context, _, _ int64, _, newHash string) error {
r.tradePasswordHash = newHash
return nil
}
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,
}}
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" {
t.Fatalf("unexpected profile states: %+v", profile)
}
}
func TestChangePasswordVerifiesAndHashes(t *testing.T) {
currentHash, err := bcrypt.GenerateFromPassword([]byte("old-secret"), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
repository := &profileRepository{security: &store.AccountSecurity{PasswordHash: string(currentHash), MemberStatus: 1}}
service := &Service{repository: repository, ready: true}
if err := service.ChangePassword(context.Background(), 0, 7, ChangePasswordInput{
OldPassword: "old-secret", NewPassword: "new-secret",
}); err != nil {
t.Fatal(err)
}
if bcrypt.CompareHashAndPassword([]byte(repository.loginPasswordHash), []byte("new-secret")) != nil {
t.Fatal("new login password was not bcrypt hashed")
}
}
func TestChangePasswordRejectsWrongCurrentPassword(t *testing.T) {
currentHash, _ := bcrypt.GenerateFromPassword([]byte("old-secret"), bcrypt.MinCost)
repository := &profileRepository{security: &store.AccountSecurity{PasswordHash: string(currentHash)}}
service := &Service{repository: repository, ready: true}
err := service.ChangePassword(context.Background(), 0, 7, ChangePasswordInput{
OldPassword: "wrong-secret", NewPassword: "new-secret",
})
if !errors.Is(err, ErrCurrentPassword) {
t.Fatalf("error = %v, want %v", err, ErrCurrentPassword)
}
}
func TestSetTradePasswordRequiresSixDigits(t *testing.T) {
loginHash, _ := bcrypt.GenerateFromPassword([]byte("login-secret"), bcrypt.MinCost)
repository := &profileRepository{security: &store.AccountSecurity{PasswordHash: string(loginHash)}}
service := &Service{repository: repository, ready: true}
for _, password := range []string{"", "12345", "1234567", "abcdef"} {
err := service.ChangeTradePassword(context.Background(), 0, 7, ChangeTradePasswordInput{
LoginPassword: "login-secret", NewTradePassword: password,
})
if !errors.Is(err, ErrTradePassword) {
t.Errorf("password %q: error = %v, want %v", password, err, ErrTradePassword)
}
}
if err := service.ChangeTradePassword(context.Background(), 0, 7, ChangeTradePasswordInput{
LoginPassword: "login-secret", NewTradePassword: "654321",
}); err != nil {
t.Fatal(err)
}
if bcrypt.CompareHashAndPassword([]byte(repository.tradePasswordHash), []byte("654321")) != nil {
t.Fatal("trade password was not bcrypt hashed")
}
}
func TestChangeTradePasswordRequiresOldPassword(t *testing.T) {
loginHash, _ := bcrypt.GenerateFromPassword([]byte("login-secret"), bcrypt.MinCost)
tradeHash, _ := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.MinCost)
repository := &profileRepository{security: &store.AccountSecurity{
PasswordHash: string(loginHash), TradePasswordHash: string(tradeHash),
}}
service := &Service{repository: repository, ready: true}
err := service.ChangeTradePassword(context.Background(), 0, 7, ChangeTradePasswordInput{
LoginPassword: "login-secret", NewTradePassword: "654321",
})
if !errors.Is(err, ErrOldTradePasswordUnset) {
t.Fatalf("error = %v, want %v", err, ErrOldTradePasswordUnset)
}
}
+405
View File
@@ -0,0 +1,405 @@
package accounts
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"regexp"
"strconv"
"strings"
"time"
"git.118im.com/im_team/im_workbench/internal/config"
"git.118im.com/im_team/im_workbench/internal/store"
"github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt"
)
var (
ErrUnavailable = errors.New("account service unavailable")
ErrUsernameInvalid = errors.New("username invalid")
ErrPhoneInvalid = errors.New("phone invalid")
ErrPasswordInvalid = errors.New("password invalid")
ErrReferralInvalid = errors.New("referral code invalid")
ErrPrivacyNotAccepted = errors.New("privacy policy not accepted")
ErrRateLimited = errors.New("login rate limited")
ErrCurrentPassword = errors.New("current password invalid")
ErrLoginPassword = errors.New("login password invalid")
ErrTradePassword = errors.New("trade password invalid")
ErrOldTradePassword = errors.New("old trade password invalid")
ErrOldTradePasswordUnset = errors.New("old trade password is required")
ErrAttachmentInvalid = errors.New("attachment invalid")
)
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}$`)
tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`)
)
type Repository interface {
RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error)
AccountByMobile(context.Context, int64, string) (*store.Account, error)
RecordLogin(context.Context, int64, int64) error
MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error)
UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error)
AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error)
UpdateLoginPassword(context.Context, int64, int64, string, string) error
UpdateTradePassword(context.Context, int64, int64, string, string) error
}
type RegisterInput struct {
Username string
Phone string
Password string
ReferralCode string
PrivacyAccepted bool
DeviceID string
}
type LoginInput struct {
Phone string
Password string
DeviceID string
}
type Result struct {
Account *store.Account
Token string
ExpiresAt time.Time
}
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"`
}
type ProfileLevel struct {
ID int64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
IconURL string `json:"icon_url"`
CommissionRate int `json:"commission_rate"`
}
type ProfileWallet struct {
AvailableAmount int64 `json:"available_amount"`
FrozenAmount int64 `json:"frozen_amount"`
TotalIncome int64 `json:"total_income"`
TotalExpense int64 `json:"total_expense"`
Currency string `json:"currency"`
Status string `json:"status"`
Version int `json:"version"`
}
type AvatarResult struct {
AttachmentID int64 `json:"attachment_id"`
URL string `json:"avatar_url"`
}
type ChangePasswordInput struct {
OldPassword string
NewPassword string
}
type ChangeTradePasswordInput struct {
LoginPassword string
OldTradePassword string
NewTradePassword string
}
type Service struct {
repository Repository
redis *redis.Client
cfg config.AuthConfig
ready bool
}
func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service {
return &Service{
repository: repository,
redis: redisClient,
cfg: cfg,
ready: repository != nil && redisClient != nil,
}
}
func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, error) {
if !s.ready {
return nil, ErrUnavailable
}
input.Username = strings.ToLower(strings.TrimSpace(input.Username))
input.Phone = strings.TrimSpace(input.Phone)
input.ReferralCode = strings.TrimSpace(input.ReferralCode)
if !usernamePattern.MatchString(input.Username) {
return nil, ErrUsernameInvalid
}
if !phonePattern.MatchString(input.Phone) {
return nil, ErrPhoneInvalid
}
if len([]rune(input.Password)) < 6 || len([]byte(input.Password)) > 72 {
return nil, ErrPasswordInvalid
}
if input.ReferralCode != "" && !referralPattern.MatchString(input.ReferralCode) {
return nil, ErrReferralInvalid
}
if !input.PrivacyAccepted {
return nil, ErrPrivacyNotAccepted
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
memberNo, err := newPublicID("M", 21)
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 {
if errors.Is(err, store.ErrReferralInvalid) {
return nil, ErrReferralInvalid
}
return nil, err
}
return s.issueSession(ctx, account, input.DeviceID)
}
func (s *Service) Login(ctx context.Context, input LoginInput) (*Result, error) {
if !s.ready {
return nil, ErrUnavailable
}
phone := strings.TrimSpace(input.Phone)
if !phonePattern.MatchString(phone) {
return nil, ErrPhoneInvalid
}
if input.Password == "" || len([]byte(input.Password)) > 72 {
return nil, store.ErrInvalidCredentials
}
rateKey := s.loginRateKey(phone)
if attempts, err := s.redis.Get(ctx, rateKey).Int(); err == nil && attempts >= 10 {
return nil, ErrRateLimited
}
account, err := s.repository.AccountByMobile(ctx, s.cfg.DefaultTenantID, phone)
if err != nil {
if errors.Is(err, store.ErrInvalidCredentials) {
s.recordLoginFailure(ctx, rateKey)
}
return nil, err
}
if bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(input.Password)) != nil {
s.recordLoginFailure(ctx, rateKey)
return nil, store.ErrInvalidCredentials
}
if err := s.repository.RecordLogin(ctx, account.TenantID, account.MemberID); err != nil {
return nil, err
}
result, err := s.issueSession(ctx, account, input.DeviceID)
if err == nil {
_ = s.redis.Del(ctx, rateKey).Err()
}
return result, err
}
func (s *Service) Profile(ctx context.Context, tenantID, memberID int64) (*Profile, error) {
if !s.ready {
return nil, ErrUnavailable
}
row, err := s.repository.MemberOverview(ctx, tenantID, memberID)
if err != nil {
return nil, err
}
return &Profile{
MemberID: row.MemberID, MemberNo: row.MemberNo, Username: row.Username,
Phone: row.Mobile, AvatarURL: row.AvatarURL, InviteCode: row.InviteCode,
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),
CreditScore: row.CreditScore,
Wallet: ProfileWallet{AvailableAmount: row.AvailableAmount, FrozenAmount: row.FrozenAmount,
TotalIncome: row.TotalIncome, TotalExpense: row.TotalExpense, Currency: row.Currency,
Status: walletStatusName(row.WalletStatus), Version: row.WalletVersion},
}, nil
}
func (s *Service) ChangeAvatar(ctx context.Context, tenantID, memberID, attachmentID int64) (*AvatarResult, error) {
if !s.ready {
return nil, ErrUnavailable
}
if attachmentID <= 0 {
return nil, ErrAttachmentInvalid
}
avatar, err := s.repository.UpdateAvatar(ctx, tenantID, memberID, attachmentID)
if errors.Is(err, store.ErrAttachmentInvalid) {
return nil, ErrAttachmentInvalid
}
if err != nil {
return nil, err
}
return &AvatarResult{AttachmentID: avatar.AttachmentID, URL: avatar.URL}, nil
}
func (s *Service) ChangePassword(ctx context.Context, tenantID, memberID int64, input ChangePasswordInput) error {
if !s.ready {
return ErrUnavailable
}
if input.OldPassword == "" || len([]byte(input.OldPassword)) > 72 {
return ErrCurrentPassword
}
if len([]rune(input.NewPassword)) < 6 || len([]byte(input.NewPassword)) > 72 {
return ErrPasswordInvalid
}
security, err := s.repository.AccountSecurity(ctx, tenantID, memberID)
if err != nil {
return err
}
if bcrypt.CompareHashAndPassword([]byte(security.PasswordHash), []byte(input.OldPassword)) != nil {
return ErrCurrentPassword
}
if bcrypt.CompareHashAndPassword([]byte(security.PasswordHash), []byte(input.NewPassword)) == nil {
return ErrPasswordInvalid
}
newHash, err := bcrypt.GenerateFromPassword([]byte(input.NewPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
return s.repository.UpdateLoginPassword(ctx, tenantID, memberID, security.PasswordHash, string(newHash))
}
func (s *Service) ChangeTradePassword(ctx context.Context, tenantID, memberID int64, input ChangeTradePasswordInput) error {
if !s.ready {
return ErrUnavailable
}
if input.LoginPassword == "" || len([]byte(input.LoginPassword)) > 72 {
return ErrLoginPassword
}
if !tradePasswordPattern.MatchString(input.NewTradePassword) {
return ErrTradePassword
}
security, err := s.repository.AccountSecurity(ctx, tenantID, memberID)
if err != nil {
return err
}
if bcrypt.CompareHashAndPassword([]byte(security.PasswordHash), []byte(input.LoginPassword)) != nil {
return ErrLoginPassword
}
if security.TradePasswordHash != "" {
if input.OldTradePassword == "" {
return ErrOldTradePasswordUnset
}
if bcrypt.CompareHashAndPassword([]byte(security.TradePasswordHash), []byte(input.OldTradePassword)) != nil {
return ErrOldTradePassword
}
if bcrypt.CompareHashAndPassword([]byte(security.TradePasswordHash), []byte(input.NewTradePassword)) == nil {
return ErrTradePassword
}
} else if input.OldTradePassword != "" {
return ErrOldTradePassword
}
newHash, err := bcrypt.GenerateFromPassword([]byte(input.NewTradePassword), bcrypt.DefaultCost)
if err != nil {
return err
}
return s.repository.UpdateTradePassword(ctx, tenantID, memberID, security.TradePasswordHash, string(newHash))
}
func (s *Service) issueSession(ctx context.Context, account *store.Account, deviceID string) (*Result, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return nil, err
}
token := base64.RawURLEncoding.EncodeToString(raw)
expiresAt := time.Now().Add(time.Duration(s.cfg.SessionTTLSeconds) * time.Second).UTC().Round(time.Millisecond)
payload, err := json.Marshal(map[string]any{
"member_id": account.MemberID,
"tenant_id": account.TenantID,
"device_id": strings.TrimSpace(deviceID),
"state": "active",
"issued_at": time.Now().UTC().Format(time.RFC3339Nano),
"expires_at": expiresAt.Format(time.RFC3339),
})
if err != nil {
return nil, err
}
if err := s.redis.Set(ctx, sessionKey(s.cfg.SessionKeyPattern, raw), payload, time.Until(expiresAt)).Err(); err != nil {
return nil, err
}
return &Result{Account: account, Token: token, ExpiresAt: expiresAt}, nil
}
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[:])
}
func (s *Service) recordLoginFailure(ctx context.Context, key string) {
pipe := s.redis.TxPipeline()
pipe.Incr(ctx, key)
pipe.Expire(ctx, key, 15*time.Minute)
_, _ = pipe.Exec(ctx)
}
func sessionKey(pattern string, rawToken []byte) string {
digest := sha256.Sum256(rawToken)
return strings.Replace(pattern, "%s", hex.EncodeToString(digest[:]), 1)
}
func newPublicID(prefix string, length int) (string, error) {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
raw := make([]byte, length-len(prefix))
if _, err := rand.Read(raw); err != nil {
return "", err
}
result := make([]byte, len(raw))
for i, value := range raw {
result[i] = alphabet[int(value)%len(alphabet)]
}
return prefix + string(result), nil
}
func memberStatusName(status int) string {
if status == 1 {
return "active"
}
return "disabled"
}
func memberCategoryName(category int) string {
if category == 2 {
return "fake"
}
return "real"
}
func walletStatusName(status int) string {
if status == 1 {
return "active"
}
return "disabled"
}
+78
View File
@@ -0,0 +1,78 @@
package accounts
import (
"context"
"errors"
"strings"
"testing"
"git.118im.com/im_team/im_workbench/internal/store"
)
type fakeRepository struct{}
func (fakeRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
return nil, errors.New("should not be called")
}
func (fakeRepository) AccountByMobile(context.Context, int64, string) (*store.Account, error) {
return nil, errors.New("should not be called")
}
func (fakeRepository) RecordLogin(context.Context, int64, int64) error {
return errors.New("should not be called")
}
func (fakeRepository) MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error) {
return nil, errors.New("should not be called")
}
func (fakeRepository) UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error) {
return nil, errors.New("should not be called")
}
func (fakeRepository) AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) {
return nil, errors.New("should not be called")
}
func (fakeRepository) UpdateLoginPassword(context.Context, int64, int64, string, string) error {
return errors.New("should not be called")
}
func (fakeRepository) UpdateTradePassword(context.Context, int64, int64, string, string) error {
return errors.New("should not be called")
}
func TestRegisterValidation(t *testing.T) {
s := &Service{repository: fakeRepository{}, ready: true}
tests := []struct {
name string
input RegisterInput
want error
}{
{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", 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},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := s.Register(context.Background(), test.input)
if !errors.Is(err, test.want) {
t.Fatalf("Register() error = %v, want %v", err, test.want)
}
})
}
}
func TestLoginRateKeyDoesNotExposePhone(t *testing.T) {
s := &Service{}
key := s.loginRateKey("13800138000")
if strings.Contains(key, "13800138000") {
t.Fatal("login rate key must not expose the phone number")
}
}
func TestLoginRequiresMainlandPhone(t *testing.T) {
s := &Service{repository: fakeRepository{}, ready: true}
for _, phone := range []string{"", "alice01", "12800138000", "+8613800138000"} {
_, err := s.Login(context.Background(), LoginInput{Phone: phone, Password: "secret1"})
if !errors.Is(err, ErrPhoneInvalid) {
t.Errorf("Login(phone=%q) error = %v, want %v", phone, err, ErrPhoneInvalid)
}
}
}
+100
View File
@@ -0,0 +1,100 @@
package app
import (
"context"
"net/http"
"os"
"path/filepath"
"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"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/redis/go-redis/v9"
)
type App struct {
store *store.Store
redis *redis.Client
api *httpapi.API
frontendPath string
}
func New(cfg *config.Config) (*App, error) {
st, err := store.Open(cfg.MySQL)
if err != nil {
return nil, err
}
redisClient := redis.NewClient(&redis.Options{
Addr: cfg.Redis.Addr, Username: cfg.Redis.Username, Password: cfg.Redis.Password, DB: cfg.Redis.DB,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := redisClient.Ping(ctx).Err(); err != nil {
_ = redisClient.Close()
_ = st.Close()
return nil, err
}
authenticator := auth.New(cfg.Auth, redisClient)
accountService := accounts.New(st, redisClient, cfg.Auth)
portalService := portal.New(st)
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
}
func (a *App) Handler() http.Handler {
mux := http.NewServeMux()
if a.frontendDir() != "" {
mux.Handle("/admin", frontendHandler(a.frontendDir()))
mux.Handle("/admin/", frontendHandler(a.frontendDir()))
}
mux.HandleFunc("/healthz", a.api.Healthz)
mux.Handle("/metrics", promhttp.Handler())
a.api.Register(mux)
return mux
}
func (a *App) frontendDir() string {
if dir := strings.TrimSpace(os.Getenv("WORKBENCH_FRONTEND_DIR")); dir != "" {
return dir
}
return strings.TrimSpace(a.frontendPath)
}
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")
if path == "" {
path = "/"
}
candidate := filepath.Join(dir, filepath.Clean(path))
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
r2 := r.Clone(r.Context())
r2.URL.Path = path
files.ServeHTTP(w, r2)
return
}
http.ServeFile(w, r, index)
})
}
func (a *App) Close() {
if a.redis != nil {
_ = a.redis.Close()
}
if a.store != nil {
_ = a.store.Close()
}
}
+198
View File
@@ -0,0 +1,198 @@
package auth
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"git.118im.com/im_team/im_workbench/internal/config"
"github.com/redis/go-redis/v9"
)
var (
ErrAuthRequired = errors.New("authentication required")
ErrForbidden = errors.New("forbidden")
)
type Principal struct {
TenantID int64
MemberID int64
DeviceID string
}
type Session struct {
MemberID int64 `json:"member_id"`
TenantID int64 `json:"tenant_id"`
DeviceID string `json:"device_id"`
State string `json:"state"`
ExpiresAt string `json:"expires_at"`
IssuedAt string `json:"issued_at"`
}
type Authenticator struct {
cfg config.AuthConfig
redis *redis.Client
}
func New(cfg config.AuthConfig, redisClient *redis.Client) *Authenticator {
return &Authenticator{cfg: cfg, redis: redisClient}
}
func (a *Authenticator) DefaultTenantID() int64 {
return a.cfg.DefaultTenantID
}
func (a *Authenticator) Authenticate(req *http.Request) (*Principal, error) {
if err := a.ValidateAPIKey(req); err != nil {
return nil, err
}
if a.cfg.AllowDevHeaderAuth {
if memberID := parseInt64Header(req, "X-Member-ID"); memberID > 0 {
return &Principal{TenantID: a.cfg.DefaultTenantID, MemberID: memberID,
DeviceID: strings.TrimSpace(req.Header.Get("X-Device-ID"))}, nil
}
}
if a.redis == nil {
return nil, ErrAuthRequired
}
token, err := bearerToken(req)
if err != nil {
return nil, err
}
session, err := a.sessionFromRedis(req.Context(), token)
if err != nil {
return nil, err
}
if session.MemberID <= 0 || session.TenantID < 0 || session.State != "active" {
return nil, ErrAuthRequired
}
expiresAt, err := time.Parse(time.RFC3339, session.ExpiresAt)
if err != nil || time.Now().After(expiresAt) {
return nil, ErrAuthRequired
}
if err := a.validateMemberSession(req.Context(), session); err != nil {
return nil, err
}
return &Principal{TenantID: session.TenantID, MemberID: session.MemberID, DeviceID: session.DeviceID}, nil
}
func (a *Authenticator) Logout(req *http.Request) error {
if _, err := a.Authenticate(req); err != nil {
return err
}
token, err := bearerToken(req)
if err != nil {
return err
}
key, err := sessionKeyFromToken(a.cfg.SessionKeyPattern, token)
if err != nil {
return err
}
if a.redis == nil {
return ErrAuthRequired
}
return a.redis.Del(req.Context(), key).Err()
}
func (a *Authenticator) RevokeMemberSessions(ctx context.Context, tenantID, memberID int64) error {
if a == nil || a.redis == nil {
return ErrAuthRequired
}
key := memberRevokedBeforeKey(tenantID, memberID)
value := time.Now().UTC().Format(time.RFC3339Nano)
ttl := time.Duration(a.cfg.SessionTTLSeconds) * time.Second
return a.redis.Set(ctx, key, value, ttl).Err()
}
func (a *Authenticator) ValidateAPIKey(req *http.Request) error {
if len(a.cfg.APIKeys) == 0 && !a.cfg.RequiredAPIKey {
return nil
}
got := strings.TrimSpace(req.Header.Get("X-API-Key"))
if got == "" {
got = strings.TrimSpace(req.URL.Query().Get("apikey"))
}
if got == "" {
return ErrForbidden
}
if len(a.cfg.APIKeys) == 0 {
return ErrForbidden
}
for _, key := range a.cfg.APIKeys {
if subtle.ConstantTimeCompare([]byte(got), []byte(strings.TrimSpace(key))) == 1 {
return nil
}
}
return ErrForbidden
}
func (a *Authenticator) sessionFromRedis(ctx context.Context, token string) (*Session, error) {
key, err := sessionKeyFromToken(a.cfg.SessionKeyPattern, token)
if err != nil {
return nil, ErrAuthRequired
}
data, err := a.redis.Get(ctx, key).Bytes()
if errors.Is(err, redis.Nil) {
return nil, ErrAuthRequired
}
if err != nil {
return nil, err
}
var session Session
if err := json.Unmarshal(data, &session); err != nil {
return nil, ErrAuthRequired
}
return &session, nil
}
func (a *Authenticator) validateMemberSession(ctx context.Context, session *Session) error {
revokedBefore, err := a.redis.Get(ctx, memberRevokedBeforeKey(session.TenantID, session.MemberID)).Result()
if errors.Is(err, redis.Nil) {
return nil
}
if err != nil {
return err
}
issuedAt, issuedErr := time.Parse(time.RFC3339Nano, session.IssuedAt)
revokedAt, revokedErr := time.Parse(time.RFC3339Nano, revokedBefore)
if issuedErr != nil || revokedErr != nil || !issuedAt.After(revokedAt) {
return ErrAuthRequired
}
return nil
}
func memberRevokedBeforeKey(tenantID, memberID int64) string {
return "workbench:session:member-revoked-before:" + strconv.FormatInt(tenantID, 10) + ":" + strconv.FormatInt(memberID, 10)
}
func sessionKeyFromToken(pattern, token string) (string, error) {
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(token))
if err != nil || len(raw) != 32 {
return "", ErrAuthRequired
}
digest := sha256.Sum256(raw)
return strings.Replace(pattern, "%s", hex.EncodeToString(digest[:]), 1), nil
}
func bearerToken(req *http.Request) (string, error) {
authorization := strings.TrimSpace(req.Header.Get("Authorization"))
token, ok := strings.CutPrefix(authorization, "Bearer ")
if !ok || strings.TrimSpace(token) == "" {
return "", ErrAuthRequired
}
return strings.TrimSpace(token), nil
}
func parseInt64Header(req *http.Request, key string) int64 {
value := strings.TrimSpace(req.Header.Get(key))
parsed, _ := strconv.ParseInt(value, 10, 64)
return parsed
}
+27
View File
@@ -0,0 +1,27 @@
package auth
import (
"encoding/base64"
"errors"
"strings"
"testing"
)
func TestSessionKeyFromToken(t *testing.T) {
token := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
key, err := sessionKeyFromToken("workbench:session:token:%s", token)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(key, "workbench:session:token:") || len(key) != len("workbench: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) {
t.Errorf("token %q: error = %v, want %v", token, err, ErrAuthRequired)
}
}
}
+179
View File
@@ -0,0 +1,179 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
mysqlDriver "github.com/go-sql-driver/mysql"
)
type Config struct {
Listen string `json:"listen"`
FrontendDir string `json:"frontend_dir"`
MySQL MySQLConfig `json:"mysql"`
Redis RedisConfig `json:"redis"`
Auth AuthConfig `json:"auth"`
ReadTimeout time.Duration `json:"-"`
WriteTimeout time.Duration `json:"-"`
}
type MySQLConfig struct {
DSN string `json:"dsn"`
MaxOpenConns int `json:"max_open_conns"`
MaxIdleConns int `json:"max_idle_conns"`
}
type RedisConfig struct {
Addr string `json:"addr"`
Username string `json:"username"`
Password string `json:"password"`
DB int `json:"db"`
}
type AuthConfig struct {
APIKeys []string `json:"api_keys"`
SessionKeyPattern string `json:"session_key_pattern"`
SessionTTLSeconds int `json:"session_ttl_seconds"`
DefaultTenantID int64 `json:"default_tenant_id"`
DataEncryptionKey string `json:"data_encryption_key"`
LookupHMACKey string `json:"lookup_hmac_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 != "" {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if err := json.Unmarshal(data, cfg); err != nil {
return nil, err
}
}
applyEnv(cfg)
if err := cfg.validate(); err != nil {
return nil, err
}
return cfg, nil
}
func defaultConfig() *Config {
return &Config{
Listen: ":6061",
FrontendDir: "web/dist",
MySQL: MySQLConfig{
DSN: "root:root@tcp(127.0.0.1:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local",
MaxOpenConns: 15,
MaxIdleConns: 5,
},
Redis: RedisConfig{Addr: "127.0.0.1:6379"},
Auth: AuthConfig{
SessionKeyPattern: "workbench:session:token:%s",
SessionTTLSeconds: 14 * 24 * 60 * 60,
DefaultTenantID: 0,
RequiredAPIKey: true,
},
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 != "" {
cfg.Auth.APIKeys = splitCSV(keys)
}
}
func (c *Config) validate() error {
if strings.TrimSpace(c.Listen) == "" {
return errors.New("listen is required")
}
if strings.TrimSpace(c.MySQL.DSN) == "" {
return errors.New("mysql dsn is required")
}
parsed, err := mysqlDriver.ParseDSN(strings.TrimPrefix(strings.TrimSpace(c.MySQL.DSN), "mysql://"))
if err != nil {
return fmt.Errorf("mysql dsn is invalid: %w", err)
}
if !strings.EqualFold(strings.TrimSpace(parsed.DBName), "olons_admin") {
return errors.New("mysql database must be olons_admin")
}
if strings.TrimSpace(c.Redis.Addr) == "" {
return errors.New("redis addr is required")
}
if !strings.Contains(c.Auth.SessionKeyPattern, "%s") {
return errors.New("session key pattern must include %s")
}
if c.Auth.SessionTTLSeconds <= 0 {
c.Auth.SessionTTLSeconds = 14 * 24 * 60 * 60
}
if c.Auth.DefaultTenantID < 0 {
return errors.New("default tenant id must not be negative")
}
return nil
}
func setString(target *string, key string) {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
*target = value
}
}
func setInt(target *int, key string) {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
if parsed, err := strconv.Atoi(value); err == nil {
*target = parsed
}
}
}
func setInt64(target *int64, key string) {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
if parsed, err := strconv.ParseInt(value, 10, 64); err == nil {
*target = parsed
}
}
}
func setBool(target *bool, key string) {
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
case "1", "true", "yes", "on":
*target = true
case "0", "false", "no", "off":
*target = false
}
}
func splitCSV(value string) []string {
parts := strings.Split(value, ",")
items := make([]string, 0, len(parts))
for _, part := range parts {
if item := strings.TrimSpace(part); item != "" {
items = append(items, item)
}
}
return items
}
+21
View File
@@ -0,0 +1,21 @@
package config
import "testing"
func TestDefaultConfigUsesOlonsAdmin(t *testing.T) {
cfg := defaultConfig()
if err := cfg.validate(); err != nil {
t.Fatal(err)
}
if cfg.Auth.DefaultTenantID != 0 {
t.Fatalf("DefaultTenantID = %d", cfg.Auth.DefaultTenantID)
}
}
func TestConfigRejectsOtherDatabase(t *testing.T) {
cfg := defaultConfig()
cfg.MySQL.DSN = "root:root@tcp(127.0.0.1:3306)/other_database?parseTime=true"
if err := cfg.validate(); err == nil {
t.Fatal("expected non-olons_admin database to be rejected")
}
}
+23
View File
@@ -0,0 +1,23 @@
package finance
import "errors"
var (
ErrUnavailable = errors.New("finance service unavailable")
ErrAccountTypeInvalid = errors.New("payment account type invalid")
ErrAccountFieldsInvalid = errors.New("payment account fields invalid")
ErrTradePassword = errors.New("trade password invalid")
ErrTradePasswordUnset = errors.New("trade password is not set")
ErrRateLimited = errors.New("trade password attempts rate limited")
ErrCurrencyInvalid = errors.New("currency invalid")
ErrAmountInvalid = errors.New("amount invalid")
ErrIdempotencyKey = errors.New("idempotency key invalid")
ErrPageInvalid = errors.New("page invalid")
ErrPageSizeInvalid = errors.New("page size invalid")
ErrStatusInvalid = errors.New("withdrawal status invalid")
ErrOrderIDInvalid = errors.New("withdrawal order id invalid")
ErrRechargeStatusInvalid = errors.New("recharge status invalid")
ErrRechargeOrderIDInvalid = errors.New("recharge order id invalid")
ErrRemarkInvalid = errors.New("remark invalid")
ErrVoucherInvalid = errors.New("voucher attachment ids invalid")
)
+994
View File
@@ -0,0 +1,994 @@
package finance
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"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"
"github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt"
)
const (
defaultPageSize = 20
maxPageSize = 100
quoteTTL = 5 * time.Minute
passwordMaxTry = 5
)
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}$`)
)
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)
CreatePaymentAccount(context.Context, 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)
CreateWithdrawal(context.Context, store.WithdrawalCreation) (*store.WithdrawalOrder, error)
WithdrawalOrders(context.Context, int64, int64, int, int, int) (*store.WithdrawalPage, error)
WithdrawalOrder(context.Context, int64, int64, int64) (*store.WithdrawalOrder, error)
RechargeChannels(context.Context, int64) ([]store.RechargeChannel, error)
CreateRecharge(context.Context, store.RechargeCreation) (*store.RechargeOrder, error)
RechargeOrders(context.Context, int64, int64, int, int, int) (*store.RechargePage, error)
RechargeOrder(context.Context, int64, int64, int64) (*store.RechargeOrder, error)
}
type Service struct {
repository Repository
redis *redis.Client
aead cipher.AEAD
lookupKey []byte
ready bool
now func() time.Time
}
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"`
}
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"`
}
type QuoteInput struct {
PaymentAccountID int64 `json:"payment_account_id"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
}
type Quote struct {
PaymentAccountID int64 `json:"payment_account_id"`
AccountMask string `json:"account_mask"`
ChannelID int64 `json:"channel_id"`
ChannelCode string `json:"channel_code"`
ChannelName string `json:"channel_name"`
RequestAmount int64 `json:"request_amount"`
FeeAmount int64 `json:"fee_amount"`
ActualAmount int64 `json:"actual_amount"`
Currency string `json:"currency"`
MinimumAmount int64 `json:"minimum_amount"`
MaximumAmount int64 `json:"maximum_amount"`
AvailableAmount int64 `json:"available_amount"`
TodayCount int `json:"today_count"`
DailyLimit int `json:"daily_limit"`
QuoteExpiresAt string `json:"quote_expires_at"`
}
type CreateWithdrawalInput struct {
PaymentAccountID int64 `json:"payment_account_id"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
TradePassword string `json:"trade_password"`
}
type Withdrawal struct {
ID int64 `json:"id"`
WithdrawalNo string `json:"withdrawal_no"`
PaymentAccountID int64 `json:"payment_account_id"`
ChannelID int64 `json:"channel_id"`
ChannelName string `json:"channel_name"`
RequestAmount int64 `json:"request_amount"`
FeeAmount int64 `json:"fee_amount"`
ActualAmount int64 `json:"actual_amount"`
Currency string `json:"currency"`
AccountMask string `json:"account_mask"`
BankName string `json:"bank_name"`
HolderMask string `json:"holder_mask"`
Status string `json:"status"`
StatusText string `json:"status_text"`
ReasonCode string `json:"reason_code"`
ReasonText string `json:"reason_text"`
ExternalNo string `json:"external_no,omitempty"`
RiskResult json.RawMessage `json:"risk_result,omitempty"`
ReviewedAt string `json:"reviewed_at,omitempty"`
ProcessedAt string `json:"processed_at,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
IdempotentReplay bool `json:"idempotent_replay,omitempty"`
}
type WithdrawalPage struct {
Items []Withdrawal `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
}
type RechargeChannel struct {
ID int64 `json:"id"`
Code string `json:"channel_code"`
Name string `json:"name"`
ChannelType int `json:"channel_type"`
MinimumAmount int64 `json:"minimum_amount"`
MaximumAmount int64 `json:"maximum_amount"`
FeeType int `json:"fee_type"`
FeeFixedAmount int64 `json:"fee_fixed_amount"`
FeeRate int `json:"fee_rate"`
}
type CreateRechargeInput struct {
ChannelID int64 `json:"channel_id"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
VoucherAttachmentIDs []int64 `json:"voucher_attachment_ids"`
Remark string `json:"remark"`
}
type Recharge struct {
ID int64 `json:"id"`
RechargeNo string `json:"recharge_no"`
ChannelID int64 `json:"channel_id"`
ChannelName string `json:"channel_name"`
RequestAmount int64 `json:"request_amount"`
PayableAmount int64 `json:"payable_amount"`
PaidAmount int64 `json:"paid_amount"`
Currency string `json:"currency"`
VoucherAttachmentIDs json.RawMessage `json:"voucher_attachment_ids"`
Status string `json:"status"`
StatusText string `json:"status_text"`
ReasonCode string `json:"reason_code"`
ReasonText string `json:"reason_text"`
Remark string `json:"remark"`
PaymentInstructions string `json:"payment_instructions"`
ExpireAt string `json:"expire_at,omitempty"`
PaidAt string `json:"paid_at,omitempty"`
ReviewedAt string `json:"reviewed_at,omitempty"`
CreditedAt string `json:"credited_at,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type RechargePage struct {
Items []Recharge `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
HasNext bool `json:"has_next"`
}
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
}
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return service
}
aead, err := cipher.NewGCM(block)
if err != nil {
return service
}
service.aead = aead
service.lookupKey = lookupKey
service.ready = repository != nil && redisClient != nil
return service
}
func (s *Service) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
if !s.ready {
return nil, ErrUnavailable
}
rows, err := s.repository.PaymentAccounts(ctx, tenantID, memberID)
if err != nil {
return nil, err
}
items := make([]PaymentAccount, 0, len(rows))
for _, row := range rows {
items = append(items, paymentAccountFromStore(row))
}
return items, nil
}
func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID int64, input PaymentAccountInput) (*PaymentAccount, error) {
if !s.ready {
return nil, ErrUnavailable
}
accountType, err := normalizePaymentAccount(&input)
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
}
}
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,
})
if err != nil {
return nil, err
}
result := paymentAccountFromStore(*row)
return &result, nil
}
func (s *Service) Quote(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, error) {
if !s.ready {
return nil, ErrUnavailable
}
if err := s.ensureWithdrawalEnabled(ctx, tenantID); err != nil {
return nil, err
}
return s.quote(ctx, tenantID, memberID, input)
}
func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64, idempotencyKey string, input CreateWithdrawalInput) (*Withdrawal, error) {
if !s.ready {
return nil, ErrUnavailable
}
idempotencyKey = strings.TrimSpace(idempotencyKey)
if !idempotencyPattern.MatchString(idempotencyKey) {
return nil, ErrIdempotencyKey
}
if err := s.ensureWithdrawalEnabled(ctx, tenantID); err != nil {
return nil, err
}
security, err := s.verifyTradePassword(ctx, tenantID, memberID, input.TradePassword)
if err != nil {
return nil, err
}
quote, account, channel, err := s.quoteData(ctx, tenantID, memberID, QuoteInput{
PaymentAccountID: input.PaymentAccountID, Amount: input.Amount, Currency: input.Currency})
if err != nil {
return nil, err
}
holderName, err := s.decrypt(account.HolderCipher)
if err != nil {
return nil, store.ErrPaymentAccountInvalid
}
accountNo, err := s.decrypt(account.AccountNoCipher)
if err != nil {
return nil, store.ErrPaymentAccountInvalid
}
reservedPhone := ""
if account.MobileCipher != "" {
reservedPhone, err = s.decrypt(account.MobileCipher)
if err != nil {
return nil, store.ErrPaymentAccountInvalid
}
}
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,
})
if err != nil {
return nil, err
}
snapshotCipher, err := s.encrypt(string(snapshot))
if err != nil {
return nil, err
}
withdrawalNo := deterministicNumber("WD", tenantID, memberID, idempotencyKey)
row, err := s.repository.CreateWithdrawal(ctx, store.WithdrawalCreation{
TenantID: tenantID, MemberID: memberID, ExpectedTradePasswordHash: security.TradePasswordHash,
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,
Currency: quote.Currency, AccountSnapshotCipher: snapshotCipher, AccountMask: account.AccountNoMask,
BankName: account.BankName, HolderMask: account.HolderMask,
AvailableLedgerNo: deterministicNumber("LA", tenantID, memberID, idempotencyKey),
FrozenLedgerNo: deterministicNumber("LF", tenantID, memberID, idempotencyKey),
})
if err != nil {
return nil, err
}
result := withdrawalFromStore(*row)
return &result, nil
}
func (s *Service) WithdrawalOrders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*WithdrawalPage, error) {
if !s.ready {
return nil, ErrUnavailable
}
if page == 0 {
page = 1
}
if pageSize == 0 {
pageSize = defaultPageSize
}
if page < 1 {
return nil, ErrPageInvalid
}
if pageSize < 1 || pageSize > maxPageSize {
return nil, ErrPageSizeInvalid
}
statusCode, ok := withdrawalStatusCode(status)
if !ok {
return nil, ErrStatusInvalid
}
rows, err := s.repository.WithdrawalOrders(ctx, tenantID, memberID, statusCode, (page-1)*pageSize, pageSize)
if err != nil {
return nil, err
}
items := make([]Withdrawal, 0, len(rows.Items))
for _, row := range rows.Items {
items = append(items, withdrawalFromStore(row))
}
return &WithdrawalPage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil
}
func (s *Service) WithdrawalOrder(ctx context.Context, tenantID, memberID, orderID int64) (*Withdrawal, error) {
if !s.ready {
return nil, ErrUnavailable
}
if orderID <= 0 {
return nil, ErrOrderIDInvalid
}
row, err := s.repository.WithdrawalOrder(ctx, tenantID, memberID, orderID)
if err != nil {
return nil, err
}
result := withdrawalFromStore(*row)
return &result, nil
}
func (s *Service) RechargeChannels(ctx context.Context, tenantID int64) ([]RechargeChannel, error) {
if !s.ready {
return nil, ErrUnavailable
}
rows, err := s.repository.RechargeChannels(ctx, tenantID)
if err != nil {
return nil, err
}
items := make([]RechargeChannel, 0, len(rows))
for _, row := range rows {
items = append(items, RechargeChannel{ID: row.ID, Code: row.Code, Name: row.Name,
ChannelType: row.ChannelType, MinimumAmount: greaterInt64(1, row.MinAmount),
MaximumAmount: row.MaxAmount, FeeType: row.FeeType,
FeeFixedAmount: row.FeeFixedAmount, FeeRate: row.FeeRate})
}
return items, nil
}
func (s *Service) CreateRecharge(ctx context.Context, tenantID, memberID int64, input CreateRechargeInput) (*Recharge, error) {
if !s.ready {
return nil, ErrUnavailable
}
input.Currency = strings.ToUpper(strings.TrimSpace(input.Currency))
input.Remark = strings.TrimSpace(input.Remark)
if input.Currency != "CNY" {
return nil, ErrCurrencyInvalid
}
if input.ChannelID <= 0 {
return nil, store.ErrRechargeChannel
}
if input.Amount <= 0 {
return nil, store.ErrRechargeAmount
}
if utf8.RuneCountInString(input.Remark) > 500 {
return nil, ErrRemarkInvalid
}
if len(input.VoucherAttachmentIDs) > 9 {
return nil, ErrVoucherInvalid
}
seen := make(map[int64]struct{}, len(input.VoucherAttachmentIDs))
for _, id := range input.VoucherAttachmentIDs {
if id <= 0 {
return nil, ErrVoucherInvalid
}
if _, exists := seen[id]; exists {
return nil, ErrVoucherInvalid
}
seen[id] = struct{}{}
}
channels, err := s.repository.RechargeChannels(ctx, tenantID)
if err != nil {
return nil, err
}
var channel *store.RechargeChannel
for i := range channels {
if channels[i].ID == input.ChannelID {
channel = &channels[i]
break
}
}
if channel == nil {
return nil, store.ErrRechargeChannel
}
if input.Amount < greaterInt64(1, channel.MinAmount) || (channel.MaxAmount > 0 && input.Amount > channel.MaxAmount) {
return nil, store.ErrRechargeAmount
}
orderNo, err := randomNumber("RC")
if err != nil {
return nil, err
}
row, err := s.repository.CreateRecharge(ctx, store.RechargeCreation{TenantID: tenantID,
MemberID: memberID, RechargeNo: orderNo, ChannelID: input.ChannelID,
RequestAmount: input.Amount, Currency: input.Currency,
VoucherAttachmentIDs: input.VoucherAttachmentIDs, Remark: input.Remark})
if err != nil {
return nil, err
}
result := rechargeFromStore(*row)
return &result, nil
}
func (s *Service) RechargeOrders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*RechargePage, error) {
if !s.ready {
return nil, ErrUnavailable
}
if page == 0 {
page = 1
}
if pageSize == 0 {
pageSize = defaultPageSize
}
if page < 1 {
return nil, ErrPageInvalid
}
if pageSize < 1 || pageSize > maxPageSize {
return nil, ErrPageSizeInvalid
}
statusCode, ok := rechargeStatusCode(status)
if !ok {
return nil, ErrRechargeStatusInvalid
}
rows, err := s.repository.RechargeOrders(ctx, tenantID, memberID, statusCode, (page-1)*pageSize, pageSize)
if err != nil {
return nil, err
}
items := make([]Recharge, 0, len(rows.Items))
for _, row := range rows.Items {
items = append(items, rechargeFromStore(row))
}
return &RechargePage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total,
HasNext: int64(page*pageSize) < rows.Total}, nil
}
func (s *Service) RechargeOrder(ctx context.Context, tenantID, memberID, orderID int64) (*Recharge, error) {
if !s.ready {
return nil, ErrUnavailable
}
if orderID <= 0 {
return nil, ErrRechargeOrderIDInvalid
}
row, err := s.repository.RechargeOrder(ctx, tenantID, memberID, orderID)
if err != nil {
return nil, err
}
result := rechargeFromStore(*row)
return &result, nil
}
func (s *Service) quote(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, error) {
quote, _, _, err := s.quoteData(ctx, tenantID, memberID, input)
return quote, err
}
func (s *Service) quoteData(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, *store.PaymentAccount, *store.WithdrawalChannel, error) {
input.Currency = strings.ToUpper(strings.TrimSpace(input.Currency))
if input.PaymentAccountID <= 0 {
return nil, nil, nil, store.ErrPaymentAccountInvalid
}
if input.Currency != "CNY" {
return nil, nil, nil, ErrCurrencyInvalid
}
if input.Amount <= 0 {
return nil, nil, nil, ErrAmountInvalid
}
account, err := s.repository.PaymentAccountForWithdrawal(ctx, tenantID, memberID, input.PaymentAccountID)
if err != nil {
return nil, nil, nil, err
}
if account.VerifyStatus != 2 {
return nil, nil, nil, store.ErrPaymentAccountInvalid
}
channel, err := s.repository.ActiveWithdrawalChannel(ctx, tenantID, channelTypes(account.AccountType))
if err != nil {
return nil, nil, nil, err
}
wallet, err := s.repository.WithdrawalWallet(ctx, tenantID, memberID)
if err != nil {
return nil, nil, nil, err
}
if wallet.AccountStatus != 1 {
return nil, nil, nil, store.ErrAccountRestricted
}
minimum := channel.MinAmount
if minimum < 1 {
minimum = 1
}
maximum := channel.MaxAmount
if maximum == 0 || maximum > wallet.AvailableAmount {
maximum = wallet.AvailableAmount
}
if input.Amount < minimum || (channel.MaxAmount > 0 && input.Amount > channel.MaxAmount) {
return nil, nil, nil, store.ErrWithdrawalAmount
}
if wallet.DailyLimit > 0 && wallet.TodayCount >= wallet.DailyLimit {
return nil, nil, nil, store.ErrWithdrawalLimit
}
if input.Amount > wallet.AvailableAmount {
return nil, nil, nil, store.ErrInsufficientBalance
}
fee, err := withdrawalFee(input.Amount, *channel)
if err != nil || fee < 0 || fee >= input.Amount {
return nil, nil, nil, store.ErrWithdrawalAmount
}
return &Quote{PaymentAccountID: account.ID, AccountMask: account.AccountNoMask,
ChannelID: channel.ID, ChannelCode: channel.Code, ChannelName: channel.Name,
RequestAmount: input.Amount, FeeAmount: fee, ActualAmount: input.Amount - fee,
Currency: input.Currency, MinimumAmount: minimum, MaximumAmount: maximum,
AvailableAmount: wallet.AvailableAmount, TodayCount: wallet.TodayCount, DailyLimit: wallet.DailyLimit,
QuoteExpiresAt: s.now().UTC().Add(quoteTTL).Format(time.RFC3339)}, account, channel, nil
}
func (s *Service) ensureWithdrawalEnabled(ctx context.Context, tenantID int64) error {
raw, err := s.repository.CurrentSiteConfig(ctx, tenantID)
if err != nil {
return err
}
var config map[string]any
if json.Unmarshal(raw, &config) != nil || !findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL") {
return store.ErrWithdrawalDisabled
}
return nil
}
func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID int64, password string) (*store.AccountSecurity, error) {
if !tradePasswordPattern.MatchString(strings.TrimSpace(password)) {
return nil, ErrTradePassword
}
key := fmt.Sprintf("workbench: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
}
if attempts >= passwordMaxTry {
return nil, ErrRateLimited
}
security, err := s.repository.AccountSecurity(ctx, tenantID, memberID)
if err != nil {
return nil, err
}
if security.TradePasswordHash == "" {
return nil, ErrTradePasswordUnset
}
if bcrypt.CompareHashAndPassword([]byte(security.TradePasswordHash), []byte(password)) != nil {
pipe := s.redis.TxPipeline()
pipe.Incr(ctx, key)
pipe.Expire(ctx, key, 15*time.Minute)
if _, rateErr := pipe.Exec(ctx); rateErr != nil {
return nil, ErrUnavailable
}
return nil, ErrTradePassword
}
if err := s.redis.Del(ctx, key).Err(); err != nil {
return nil, ErrUnavailable
}
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))
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
}
}
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,
CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
}
func withdrawalFromStore(row store.WithdrawalOrder) Withdrawal {
risk := row.RiskResult
if len(risk) == 0 {
risk = json.RawMessage(`{}`)
}
return Withdrawal{ID: row.ID, WithdrawalNo: row.WithdrawalNo, PaymentAccountID: row.PaymentAccountID,
ChannelID: row.ChannelID, ChannelName: row.ChannelName, RequestAmount: row.RequestAmount,
FeeAmount: row.FeeAmount, ActualAmount: row.ActualAmount, Currency: row.Currency,
AccountMask: row.AccountMask, BankName: row.BankName, HolderMask: row.HolderMask,
Status: withdrawalStatusName(row.Status), StatusText: withdrawalStatusText(row.Status),
ReasonCode: row.ReviewReasonCode, ReasonText: row.ReviewReason, ExternalNo: row.ExternalNo,
RiskResult: risk, ReviewedAt: row.ReviewedAt, ProcessedAt: row.ProcessedAt,
CompletedAt: row.CompletedAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
IdempotentReplay: row.Replayed}
}
func rechargeFromStore(row store.RechargeOrder) Recharge {
vouchers := row.VoucherAttachmentIDs
if len(vouchers) == 0 {
vouchers = json.RawMessage(`[]`)
}
result := Recharge{ID: row.ID, RechargeNo: row.RechargeNo, ChannelID: row.ChannelID,
ChannelName: row.ChannelName, RequestAmount: row.RequestAmount, PayableAmount: row.RequestAmount,
PaidAmount: row.PaidAmount, Currency: row.Currency, VoucherAttachmentIDs: vouchers,
Status: rechargeStatusName(row.Status), StatusText: rechargeStatusText(row.Status),
ReasonCode: row.ReviewReasonCode, ReasonText: row.ReviewReason, Remark: row.Remark,
PaymentInstructions: "沙箱订单,请按页面提示完成后续操作,不要向个人账户转账。",
PaidAt: row.PaidAt, ReviewedAt: row.ReviewedAt, CreditedAt: row.CreditedAt,
CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
if row.Status == 1 && row.CreatedAt != "" {
if created, err := time.Parse(time.RFC3339, row.CreatedAt); err == nil {
result.ExpireAt = created.Add(30 * time.Minute).Format(time.RFC3339)
}
}
return result
}
func rechargeStatusCode(value string) (int, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "all":
return 0, true
case "pending_payment":
return 1, true
case "reviewing", "processing":
return 2, true
case "succeeded":
return 3, true
case "failed":
return 4, true
case "expired":
return 5, true
case "cancelled":
return 6, true
default:
return 0, false
}
}
func rechargeStatusName(value int) string {
names := map[int]string{1: "pending_payment", 2: "reviewing", 3: "succeeded", 4: "failed", 5: "expired", 6: "cancelled"}
if name := names[value]; name != "" {
return name
}
return "unknown"
}
func rechargeStatusText(value int) string {
names := map[int]string{1: "待支付", 2: "审核中", 3: "已成功", 4: "失败", 5: "已过期", 6: "已取消"}
if name := names[value]; name != "" {
return name
}
return "未知状态"
}
func withdrawalFee(amount int64, channel store.WithdrawalChannel) (int64, error) {
if amount <= 0 || channel.FeeRate < 0 || channel.FeeRate > 10000 || channel.FeeFixedAmount < 0 {
return 0, store.ErrWithdrawalChannel
}
rate := int64(channel.FeeRate)
rateFee := (amount/10000)*rate + ((amount%10000)*rate+9999)/10000
switch channel.FeeType {
case 1:
return channel.FeeFixedAmount, nil
case 2:
return rateFee, nil
case 3:
return channel.FeeFixedAmount + rateFee, nil
default:
return 0, store.ErrWithdrawalChannel
}
}
func channelTypes(accountType int) []int {
specific := map[int]int{1: 3, 2: 4, 3: 3, 4: 2, 5: 1}[accountType]
if specific == 0 || specific == 4 {
return []int{4}
}
return []int{specific, 4}
}
func accountTypeName(value int) string {
names := map[int]string{1: "bank_card", 2: "digital_wallet", 3: "unionpay", 4: "alipay", 5: "wechat"}
if name := names[value]; name != "" {
return name
}
return "unknown"
}
func verifyStatusName(value int) string {
names := map[int]string{1: "pending", 2: "verified", 3: "rejected"}
if name := names[value]; name != "" {
return name
}
return "unknown"
}
func withdrawalStatusCode(value string) (int, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "all":
return 0, true
case "pending_review":
return 1, true
case "reviewing":
return 2, true
case "processing":
return 3, true
case "succeeded":
return 4, true
case "failed":
return 5, true
case "rejected":
return 6, true
case "cancelled":
return 7, true
default:
return 0, false
}
}
func withdrawalStatusName(value int) string {
names := map[int]string{1: "pending_review", 2: "reviewing", 3: "processing", 4: "succeeded", 5: "failed", 6: "rejected", 7: "cancelled"}
if name := names[value]; name != "" {
return name
}
return "unknown"
}
func withdrawalStatusText(value int) string {
names := map[int]string{1: "待审核", 2: "审核中", 3: "处理中", 4: "已成功", 5: "失败", 6: "已驳回", 7: "已取消"}
if name := names[value]; name != "" {
return name
}
return "未知状态"
}
func (s *Service) encrypt(value string) (string, error) {
nonce := make([]byte, s.aead.NonceSize())
if _, err := rand.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) decrypt(value string) (string, error) {
payload, err := base64.RawStdEncoding.DecodeString(strings.TrimSpace(value))
if err != nil || len(payload) <= s.aead.NonceSize() {
return "", errors.New("encrypted value 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
}
func (s *Service) lookupHash(value string) string {
mac := hmac.New(sha256.New, s.lookupKey)
_, _ = mac.Write([]byte(value))
return hex.EncodeToString(mac.Sum(nil))
}
func deterministicNumber(prefix string, tenantID, memberID int64, key string) string {
digest := sha256.Sum256([]byte(strconv.FormatInt(tenantID, 10) + ":" + strconv.FormatInt(memberID, 10) + ":" + key + ":" + prefix))
return prefix + strings.ToUpper(hex.EncodeToString(digest[:16]))
}
func randomNumber(prefix string) (string, error) {
random := make([]byte, 12)
if _, err := rand.Read(random); err != nil {
return "", err
}
return prefix + time.Now().UTC().Format("20060102150405") + strings.ToUpper(hex.EncodeToString(random)), nil
}
func greaterInt64(left, right int64) int64 {
if left > right {
return left
}
return right
}
func decodeKey(value string) ([]byte, error) {
value = strings.TrimSpace(value)
if decoded, err := base64.StdEncoding.DecodeString(value); err == nil {
return decoded, nil
}
return base64.RawStdEncoding.DecodeString(value)
}
func maskPhone(value string) string {
if len(value) != 11 {
return ""
}
return value[:3] + "****" + value[7:]
}
func maskName(value string) string {
runes := []rune(value)
if len(runes) == 0 {
return ""
}
if len(runes) == 1 {
return string(runes[0]) + "*"
}
return string(runes[0]) + strings.Repeat("*", len(runes)-1)
}
func maskAccount(value string) string {
runes := []rune(value)
if len(runes) <= 4 {
return strings.Repeat("*", len(runes))
}
if len(runes) <= 6 {
return string(runes[0]) + strings.Repeat("*", len(runes)-2) + string(runes[len(runes)-1])
}
prefix := 4
if len(runes) < 12 {
prefix = 2
}
return string(runes[:prefix]) + strings.Repeat("*", len(runes)-prefix-4) + string(runes[len(runes)-4:])
}
func validHumanName(value string) bool {
count := utf8.RuneCountInString(value)
if count < 2 || count > 64 {
return false
}
for _, r := range value {
if unicode.IsControl(r) || r == '<' || r == '>' {
return false
}
}
return true
}
func allDigits(value string) bool {
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return value != ""
}
func findBool(config map[string]any, keys ...string) bool {
for _, key := range keys {
for candidate, value := range config {
if strings.EqualFold(candidate, key) {
switch typed := value.(type) {
case bool:
return typed
case float64:
return typed == 1
case string:
return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on")
}
}
}
}
for _, value := range config {
if nested, ok := value.(map[string]any); ok && findBool(nested, keys...) {
return true
}
}
return false
}
+180
View File
@@ -0,0 +1,180 @@
package finance
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"git.118im.com/im_team/im_workbench/internal/config"
"git.118im.com/im_team/im_workbench/internal/store"
)
type fakeRepository struct {
config json.RawMessage
account *store.PaymentAccount
channel *store.WithdrawalChannel
wallet *store.WithdrawalWallet
orders []store.WithdrawalOrder
rechargeChannels []store.RechargeChannel
rechargeOrders []store.RechargeOrder
createdRecharge store.RechargeCreation
}
func (f *fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) {
return f.config, nil
}
func (f *fakeRepository) AccountSecurity(context.Context, int64, int64) (*store.AccountSecurity, error) {
return nil, errors.New("not implemented")
}
func (f *fakeRepository) PaymentAccounts(context.Context, int64, int64) ([]store.PaymentAccount, error) {
return nil, nil
}
func (f *fakeRepository) CreatePaymentAccount(context.Context, 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
}
func (f *fakeRepository) ActiveWithdrawalChannel(context.Context, int64, []int) (*store.WithdrawalChannel, error) {
return f.channel, nil
}
func (f *fakeRepository) WithdrawalWallet(context.Context, int64, int64) (*store.WithdrawalWallet, error) {
return f.wallet, nil
}
func (f *fakeRepository) CreateWithdrawal(context.Context, store.WithdrawalCreation) (*store.WithdrawalOrder, error) {
return nil, errors.New("not implemented")
}
func (f *fakeRepository) WithdrawalOrders(context.Context, int64, int64, int, int, int) (*store.WithdrawalPage, error) {
return &store.WithdrawalPage{Items: f.orders, Total: int64(len(f.orders))}, nil
}
func (f *fakeRepository) WithdrawalOrder(context.Context, int64, int64, int64) (*store.WithdrawalOrder, error) {
return nil, errors.New("not implemented")
}
func (f *fakeRepository) RechargeChannels(context.Context, int64) ([]store.RechargeChannel, error) {
return f.rechargeChannels, nil
}
func (f *fakeRepository) CreateRecharge(_ context.Context, input store.RechargeCreation) (*store.RechargeOrder, error) {
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
}
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
}
func (f *fakeRepository) RechargeOrder(context.Context, int64, int64, int64) (*store.RechargeOrder, error) {
return nil, errors.New("not implemented")
}
func TestQuoteCalculatesRateFeeAndLimits(t *testing.T) {
repository := &fakeRepository{config: json.RawMessage(`{"features":{"withdrawal_enabled":true}}`),
account: &store.PaymentAccount{ID: 3, AccountType: 1, AccountNoMask: "6222********1234", VerifyStatus: 2},
channel: &store.WithdrawalChannel{ID: 1, Code: "bank", Name: "银行卡", ChannelType: 3,
MinAmount: 1000, MaxAmount: 100000, FeeType: 3, FeeFixedAmount: 100, FeeRate: 25},
wallet: &store.WithdrawalWallet{AvailableAmount: 80000, AccountStatus: 1, DailyLimit: 3, TodayCount: 1}}
service := &Service{repository: repository, ready: true, now: func() time.Time {
return time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)
}}
result, err := service.Quote(context.Background(), 0, 1, QuoteInput{PaymentAccountID: 3, Amount: 50000, Currency: "cny"})
if err != nil {
t.Fatal(err)
}
if result.FeeAmount != 225 || result.ActualAmount != 49775 || result.MaximumAmount != 80000 ||
result.QuoteExpiresAt != "2026-10-09T02:05:00Z" {
t.Fatalf("unexpected quote: %#v", result)
}
}
func TestQuoteFailsClosed(t *testing.T) {
service := &Service{repository: &fakeRepository{config: json.RawMessage(`{}`)}, ready: true, now: time.Now}
_, err := service.Quote(context.Background(), 0, 1, QuoteInput{PaymentAccountID: 1, Amount: 100, Currency: "CNY"})
if !errors.Is(err, store.ErrWithdrawalDisabled) {
t.Fatalf("expected disabled error, got %v", err)
}
}
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)
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)
}
}
func TestMaskAccountShortValueDoesNotLeak(t *testing.T) {
if got := maskAccount("abcde"); got != "a***e" {
t.Fatalf("unexpected mask %q", got)
}
}
func TestWithdrawalStatusValidation(t *testing.T) {
service := &Service{repository: &fakeRepository{}, ready: true, now: time.Now}
_, err := service.WithdrawalOrders(context.Background(), 0, 1, 1, 20, "bad")
if !errors.Is(err, ErrStatusInvalid) {
t.Fatalf("expected status error, got %v", err)
}
}
func TestCreateRechargeValidatesChannelAndMapsOrder(t *testing.T) {
repository := &fakeRepository{rechargeChannels: []store.RechargeChannel{{
ID: 4, Code: "manual", Name: "人工审核", MinAmount: 100, MaxAmount: 100000,
}}}
service := &Service{repository: repository, ready: true, now: time.Now}
result, err := service.CreateRecharge(context.Background(), 0, 9, CreateRechargeInput{
ChannelID: 4, Amount: 50000, Currency: "cny", Remark: "测试",
})
if err != nil {
t.Fatal(err)
}
if result.Status != "pending_payment" || result.RequestAmount != 50000 ||
repository.createdRecharge.MemberID != 9 || repository.createdRecharge.Currency != "CNY" {
t.Fatalf("unexpected recharge: %#v, creation: %#v", result, repository.createdRecharge)
}
}
func TestRechargeOrdersMapsStatusAndPagination(t *testing.T) {
repository := &fakeRepository{rechargeOrders: []store.RechargeOrder{{
ID: 3, RechargeNo: "RC3", RequestAmount: 20000, Currency: "CNY", Status: 3,
}}}
service := &Service{repository: repository, ready: true, now: time.Now}
result, err := service.RechargeOrders(context.Background(), 0, 9, 1, 20, "succeeded")
if err != nil {
t.Fatal(err)
}
if result.Total != 1 || len(result.Items) != 1 || result.Items[0].Status != "succeeded" || result.HasNext {
t.Fatalf("unexpected recharge page: %#v", result)
}
if _, err = service.RechargeOrders(context.Background(), 0, 9, 1, 20, "invalid"); !errors.Is(err, ErrRechargeStatusInvalid) {
t.Fatalf("expected recharge status error, got %v", err)
}
}
func TestDeterministicNumberIsStableAndScoped(t *testing.T) {
first := deterministicNumber("WD", 0, 1, "request-key")
if first != deterministicNumber("WD", 0, 1, "request-key") || first == deterministicNumber("WD", 0, 2, "request-key") {
t.Fatal("deterministic number is not stable or member scoped")
}
}
func TestEncryptionRoundTrip(t *testing.T) {
service := New(&fakeRepository{}, nil, config.AuthConfig{
DataEncryptionKey: "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=",
LookupHMACKey: "ZmVkY2JhOTg3NjU0MzIxMGZlZGNiYTk4NzY1NDMyMTA=",
})
ciphertext, err := service.encrypt("6222000000001234")
if err != nil {
t.Fatal(err)
}
plain, err := service.decrypt(ciphertext)
if err != nil || plain != "6222000000001234" {
t.Fatalf("unexpected round trip: %q, %v", plain, err)
}
}
+144
View File
@@ -0,0 +1,144 @@
package httpapi
import (
"errors"
"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"
)
type accountRegisterRequest struct {
Username string `json:"username"`
Phone string `json:"phone"`
Password string `json:"password"`
ReferralCode string `json:"referral_code,omitempty"`
PrivacyAccepted bool `json:"privacy_accepted"`
}
type accountLoginRequest struct {
Phone string `json:"phone"`
Password string `json:"password"`
}
func (a *API) accountRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w, r)
return
}
if !a.validatePublicClient(w, r) {
return
}
var body accountRegisterRequest
if !decodeBody(w, r, &body) {
return
}
result, err := a.accounts.Register(r.Context(), accountsvc.RegisterInput{
Username: body.Username, Phone: body.Phone, Password: body.Password,
ReferralCode: body.ReferralCode, PrivacyAccepted: body.PrivacyAccepted,
DeviceID: strings.TrimSpace(r.Header.Get("X-Device-ID")),
})
if err != nil {
writeAccountError(w, r, err)
return
}
writeSuccess(w, r, accountResultPayload(result))
}
func (a *API) accountLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w, r)
return
}
if !a.validatePublicClient(w, r) {
return
}
var body accountLoginRequest
if !decodeBody(w, r, &body) {
return
}
result, err := a.accounts.Login(r.Context(), accountsvc.LoginInput{
Phone: body.Phone, Password: body.Password,
DeviceID: strings.TrimSpace(r.Header.Get("X-Device-ID")),
})
if err != nil {
writeAccountError(w, r, err)
return
}
writeSuccess(w, r, accountResultPayload(result))
}
func (a *API) accountLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w, r)
return
}
if a.auth == nil {
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
return
}
if err := a.auth.Logout(r); err != nil {
switch {
case errors.Is(err, auth.ErrAuthRequired):
writeError(w, r, http.StatusUnauthorized, 30041, "AUTH_REQUIRED", "authentication required", nil)
case errors.Is(err, auth.ErrForbidden):
writeError(w, r, http.StatusForbidden, 30003, reasonForbidden, "forbidden", nil)
default:
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
}
return
}
writeSuccess(w, r, nil)
}
func (a *API) validatePublicClient(w http.ResponseWriter, r *http.Request) bool {
if a.accounts == nil || a.auth == nil {
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
return false
}
if err := a.auth.ValidateAPIKey(r); err != nil {
writeError(w, r, http.StatusForbidden, 30003, reasonForbidden, "valid API key required", nil)
return false
}
return true
}
func accountResultPayload(result *accountsvc.Result) map[string]any {
return map[string]any{
"member_id": result.Account.MemberID,
"member_no": result.Account.MemberNo,
"username": result.Account.Username,
"phone": result.Account.Mobile,
"invite_code": result.Account.InviteCode,
"token": result.Token,
"token_type": "Bearer",
"expires_at": result.ExpiresAt,
}
}
func writeAccountError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, accountsvc.ErrUsernameInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "USERNAME_INVALID_FORMAT", "validation failed", map[string]any{"field": "username"})
case errors.Is(err, accountsvc.ErrPhoneInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "PHONE_INVALID_FORMAT", "validation failed", map[string]any{"field": "phone"})
case errors.Is(err, accountsvc.ErrPasswordInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "PASSWORD_INVALID_FORMAT", "validation failed", map[string]any{"field": "password"})
case errors.Is(err, accountsvc.ErrReferralInvalid), errors.Is(err, store.ErrReferralInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "REFERRAL_CODE_INVALID", "validation failed", map[string]any{"field": "referral_code"})
case errors.Is(err, accountsvc.ErrPrivacyNotAccepted):
writeError(w, r, http.StatusBadRequest, 30000, "PRIVACY_POLICY_NOT_ACCEPTED", "validation failed", map[string]any{"field": "privacy_accepted"})
case errors.Is(err, store.ErrAccountExists):
writeError(w, r, http.StatusConflict, 30040, "ACCOUNT_ALREADY_REGISTERED", "account already registered", nil)
case errors.Is(err, store.ErrInvalidCredentials):
writeError(w, r, http.StatusUnauthorized, 30043, "INVALID_CREDENTIALS", "invalid phone or password", nil)
case errors.Is(err, accountsvc.ErrRateLimited):
writeError(w, r, http.StatusTooManyRequests, 30020, "RATE_LIMITED", "too many login attempts", nil)
case errors.Is(err, accountsvc.ErrUnavailable):
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
default:
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
}
}
+60
View File
@@ -0,0 +1,60 @@
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"
)
type API struct {
auth *auth.Authenticator
accounts *accounts.Service
portal *portal.Service
tasks *tasks.Service
mall *mall.Service
finance *finance.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 (a *API) Register(mux *http.ServeMux) {
mux.HandleFunc("/v1/auth/register", a.accountRegister)
mux.HandleFunc("/v1/auth/login", a.accountLogin)
mux.HandleFunc("/v1/auth/logout", a.accountLogout)
mux.HandleFunc("/v1/me", a.memberProfile)
mux.HandleFunc("/v1/me/avatar", a.memberAvatar)
mux.HandleFunc("/v1/me/password", a.memberPassword)
mux.HandleFunc("/v1/me/trade-password", a.memberTradePassword)
mux.HandleFunc("/v1/home", a.home)
mux.HandleFunc("/v1/banners", a.banners)
mux.HandleFunc("/v1/contents/", a.content)
mux.HandleFunc("/v1/member-levels", a.memberLevels)
mux.HandleFunc("/v1/leaderboard", a.leaderboard)
mux.HandleFunc("/v1/customer-services", a.customerServices)
mux.HandleFunc("/v1/task-halls", a.taskHalls)
mux.HandleFunc("/v1/tasks/eligibility", a.taskEligibility)
mux.HandleFunc("/v1/tasks/stats", a.taskStats)
mux.HandleFunc("/v1/task-orders/claim", a.taskClaim)
mux.HandleFunc("/v1/task-orders", a.taskOrders)
mux.HandleFunc("/v1/task-orders/", a.taskOrderResource)
mux.HandleFunc("/v1/manual-products", a.manualProducts)
mux.HandleFunc("/v1/manual-products/", a.manualProduct)
mux.HandleFunc("/v1/payment-accounts", a.paymentAccounts)
mux.HandleFunc("/v1/recharge/channels", a.rechargeChannels)
mux.HandleFunc("/v1/recharge-orders", a.rechargeOrders)
mux.HandleFunc("/v1/recharge-orders/", a.rechargeOrder)
mux.HandleFunc("/v1/withdrawal-orders/quote", a.withdrawalQuote)
mux.HandleFunc("/v1/withdrawal-orders", a.withdrawalOrders)
mux.HandleFunc("/v1/withdrawal-orders/", a.withdrawalOrder)
}
func (a *API) Healthz(w http.ResponseWriter, r *http.Request) {
writeSuccess(w, r, map[string]any{"status": "ok", "service": "workbench"})
}
+302
View File
@@ -0,0 +1,302 @@
package httpapi
import (
"errors"
"net/http"
"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"
)
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)
return
}
principal, ok := a.authenticateFinanceMember(w, r)
if !ok {
return
}
if r.Method == http.MethodGet {
result, err := a.finance.PaymentAccounts(r.Context(), principal.TenantID, principal.MemberID)
if err != nil {
writeFinanceError(w, r, err)
return
}
writeSuccess(w, r, result)
return
}
var body finance.PaymentAccountInput
if !decodeBody(w, r, &body) {
return
}
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) withdrawalQuote(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w, r, http.MethodPost)
return
}
principal, ok := a.authenticateFinanceMember(w, r)
if !ok {
return
}
var body finance.QuoteInput
if !decodeBody(w, r, &body) {
return
}
result, err := a.finance.Quote(r.Context(), principal.TenantID, principal.MemberID, body)
if err != nil {
writeFinanceError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func (a *API) rechargeChannels(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
}
result, err := a.finance.RechargeChannels(r.Context(), principal.TenantID)
if err != nil {
writeFinanceError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func (a *API) rechargeOrders(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
methodNotAllowed(w, r, http.MethodGet, http.MethodPost)
return
}
principal, ok := a.authenticateFinanceMember(w, r)
if !ok {
return
}
if r.Method == http.MethodGet {
page, ok := optionalPositiveInt(w, r, "page")
if !ok {
writeFinanceError(w, r, finance.ErrPageInvalid)
return
}
pageSize, ok := optionalPositiveInt(w, r, "page_size")
if !ok {
writeFinanceError(w, r, finance.ErrPageSizeInvalid)
return
}
result, err := a.finance.RechargeOrders(r.Context(), principal.TenantID, principal.MemberID,
page, pageSize, r.URL.Query().Get("status"))
if err != nil {
writeFinanceError(w, r, err)
return
}
writeSuccess(w, r, result)
return
}
var body finance.CreateRechargeInput
if !decodeBody(w, r, &body) {
return
}
result, err := a.finance.CreateRecharge(r.Context(), principal.TenantID, principal.MemberID, body)
if err != nil {
writeFinanceError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func (a *API) rechargeOrder(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, r, http.MethodGet)
return
}
orderIDText := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/recharge-orders/"), "/")
if orderIDText == "" || strings.Contains(orderIDText, "/") {
writeFinanceError(w, r, finance.ErrRechargeOrderIDInvalid)
return
}
orderID, err := strconv.ParseInt(orderIDText, 10, 64)
if err != nil || orderID <= 0 {
writeFinanceError(w, r, finance.ErrRechargeOrderIDInvalid)
return
}
principal, ok := a.authenticateFinanceMember(w, r)
if !ok {
return
}
result, err := a.finance.RechargeOrder(r.Context(), principal.TenantID, principal.MemberID, orderID)
if err != nil {
writeFinanceError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func (a *API) withdrawalOrders(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
methodNotAllowed(w, r, http.MethodGet, http.MethodPost)
return
}
principal, ok := a.authenticateFinanceMember(w, r)
if !ok {
return
}
if r.Method == http.MethodGet {
page, ok := optionalPositiveInt(w, r, "page")
if !ok {
writeFinanceError(w, r, finance.ErrPageInvalid)
return
}
pageSize, ok := optionalPositiveInt(w, r, "page_size")
if !ok {
writeFinanceError(w, r, finance.ErrPageSizeInvalid)
return
}
result, err := a.finance.WithdrawalOrders(r.Context(), principal.TenantID, principal.MemberID,
page, pageSize, r.URL.Query().Get("status"))
if err != nil {
writeFinanceError(w, r, err)
return
}
writeSuccess(w, r, result)
return
}
var body finance.CreateWithdrawalInput
if !decodeBody(w, r, &body) {
return
}
result, err := a.finance.CreateWithdrawal(r.Context(), principal.TenantID, principal.MemberID,
r.Header.Get("Idempotency-Key"), body)
if err != nil {
writeFinanceError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func (a *API) withdrawalOrder(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, r, http.MethodGet)
return
}
orderIDText := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/withdrawal-orders/"), "/")
if orderIDText == "" || strings.Contains(orderIDText, "/") {
writeFinanceError(w, r, finance.ErrOrderIDInvalid)
return
}
orderID, err := strconv.ParseInt(orderIDText, 10, 64)
if err != nil || orderID <= 0 {
writeFinanceError(w, r, finance.ErrOrderIDInvalid)
return
}
principal, ok := a.authenticateFinanceMember(w, r)
if !ok {
return
}
result, err := a.finance.WithdrawalOrder(r.Context(), principal.TenantID, principal.MemberID, orderID)
if err != nil {
writeFinanceError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func (a *API) authenticateFinanceMember(w http.ResponseWriter, r *http.Request) (*auth.Principal, bool) {
if a.finance == nil {
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
return nil, false
}
principal, ok := a.authenticateMember(w, r)
if !ok {
return nil, false
}
return principal, true
}
func writeFinanceError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, finance.ErrAccountTypeInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "ACCOUNT_TYPE_INVALID", "validation failed", map[string]any{"field": "account_type"})
case errors.Is(err, finance.ErrAccountFieldsInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "PAYMENT_ACCOUNT_FIELDS_INVALID", "validation failed", nil)
case errors.Is(err, finance.ErrTradePassword):
writeError(w, r, http.StatusBadRequest, 30000, "TRADE_PASSWORD_INVALID", "trade password is invalid", map[string]any{"field": "trade_password"})
case errors.Is(err, finance.ErrTradePasswordUnset):
writeError(w, r, http.StatusUnprocessableEntity, 30022, "TRADE_PASSWORD_NOT_SET", "trade password is not set", nil)
case errors.Is(err, finance.ErrRateLimited):
writeError(w, r, http.StatusTooManyRequests, 30020, "RATE_LIMITED", "too many trade password attempts", map[string]any{"retry_after_seconds": 900})
case errors.Is(err, finance.ErrCurrencyInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "CURRENCY_INVALID", "validation failed", map[string]any{"field": "currency"})
case errors.Is(err, finance.ErrAmountInvalid), errors.Is(err, store.ErrWithdrawalAmount):
writeError(w, r, http.StatusUnprocessableEntity, 30022, "WITHDRAWAL_AMOUNT_INVALID", "withdrawal amount is outside the allowed range", nil)
case errors.Is(err, store.ErrRechargeAmount):
writeError(w, r, http.StatusUnprocessableEntity, 30022, "RECHARGE_AMOUNT_INVALID", "recharge amount is outside the allowed range", nil)
case errors.Is(err, finance.ErrRemarkInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "RECHARGE_REMARK_INVALID", "validation failed", map[string]any{"field": "remark"})
case errors.Is(err, finance.ErrVoucherInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "RECHARGE_VOUCHER_INVALID", "validation failed", map[string]any{"field": "voucher_attachment_ids"})
case errors.Is(err, finance.ErrIdempotencyKey):
writeError(w, r, http.StatusBadRequest, 30000, "IDEMPOTENCY_KEY_INVALID", "valid Idempotency-Key header is required", map[string]any{"field": "Idempotency-Key"})
case errors.Is(err, finance.ErrPageInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_INVALID", "validation failed", map[string]any{"field": "page"})
case errors.Is(err, finance.ErrPageSizeInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_SIZE_INVALID", "validation failed", map[string]any{"field": "page_size"})
case errors.Is(err, finance.ErrStatusInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "WITHDRAWAL_STATUS_INVALID", "validation failed", map[string]any{"field": "status"})
case errors.Is(err, finance.ErrRechargeStatusInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "RECHARGE_STATUS_INVALID", "validation failed", map[string]any{"field": "status"})
case errors.Is(err, finance.ErrRechargeOrderIDInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "RECHARGE_ORDER_ID_INVALID", "validation failed", map[string]any{"field": "order_id"})
case errors.Is(err, finance.ErrOrderIDInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "WITHDRAWAL_ORDER_ID_INVALID", "validation failed", map[string]any{"field": "order_id"})
case errors.Is(err, store.ErrPaymentAccountExists):
writeError(w, r, http.StatusConflict, 30040, "PAYMENT_ACCOUNT_EXISTS", "payment account already exists", nil)
case errors.Is(err, store.ErrPaymentAccountInvalid):
writeError(w, r, http.StatusUnprocessableEntity, 30022, "PAYMENT_ACCOUNT_INVALID", "payment account is unavailable or unverified", nil)
case errors.Is(err, store.ErrWithdrawalDisabled):
writeError(w, r, http.StatusUnprocessableEntity, 30022, "WITHDRAWAL_DISABLED", "withdrawal feature is disabled", nil)
case errors.Is(err, store.ErrWithdrawalChannel):
writeError(w, r, http.StatusServiceUnavailable, 30053, "WITHDRAWAL_CHANNEL_UNAVAILABLE", "withdrawal channel is unavailable", nil)
case errors.Is(err, store.ErrRechargeChannel):
writeError(w, r, http.StatusServiceUnavailable, 30053, "RECHARGE_CHANNEL_UNAVAILABLE", "recharge channel is unavailable", nil)
case errors.Is(err, store.ErrWithdrawalLimit):
writeError(w, r, http.StatusUnprocessableEntity, 30022, "WITHDRAWAL_DAILY_LIMIT_REACHED", "daily withdrawal limit reached", nil)
case errors.Is(err, store.ErrInsufficientBalance):
writeError(w, r, http.StatusUnprocessableEntity, 30022, "INSUFFICIENT_BALANCE", "insufficient available balance", nil)
case errors.Is(err, store.ErrAccountRestricted):
writeError(w, r, http.StatusUnprocessableEntity, 30022, "ACCOUNT_RESTRICTED", "account is restricted", nil)
case errors.Is(err, store.ErrAccountNotFound):
writeError(w, r, http.StatusUnprocessableEntity, 30022, "ACCOUNT_RESTRICTED", "account is restricted", nil)
case errors.Is(err, store.ErrWithdrawalNotFound):
writeError(w, r, http.StatusNotFound, 30044, "WITHDRAWAL_ORDER_NOT_FOUND", "withdrawal order not found", nil)
case errors.Is(err, store.ErrRechargeNotFound):
writeError(w, r, http.StatusNotFound, 30044, "RECHARGE_ORDER_NOT_FOUND", "recharge order not found", nil)
case errors.Is(err, store.ErrRechargeVoucher):
writeError(w, r, http.StatusBadRequest, 30000, "RECHARGE_VOUCHER_INVALID", "validation failed", map[string]any{"field": "voucher_attachment_ids"})
case errors.Is(err, store.ErrIdempotencyConflict):
writeError(w, r, http.StatusConflict, 30040, "IDEMPOTENCY_CONFLICT", "Idempotency-Key was reused with different request data", nil)
case errors.Is(err, store.ErrAttachmentInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "QR_ATTACHMENT_INVALID", "validation failed", map[string]any{"field": "qr_attachment_id"})
case errors.Is(err, store.ErrConcurrentUpdate):
writeError(w, r, http.StatusConflict, 30040, "ACCOUNT_CHANGED", "account changed, retry the operation", nil)
case errors.Is(err, finance.ErrUnavailable):
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
default:
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
}
}
+104
View File
@@ -0,0 +1,104 @@
package httpapi
import (
"errors"
"net/http"
"strconv"
"strings"
"git.118im.com/im_team/im_workbench/internal/mall"
"git.118im.com/im_team/im_workbench/internal/store"
)
func (a *API) manualProducts(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, r, http.MethodGet)
return
}
if a.mall == nil {
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
return
}
principal, ok := a.authenticateMember(w, r)
if !ok {
return
}
page, ok := optionalPositiveInt(w, r, "page")
if !ok {
writeMallError(w, r, mall.ErrPageInvalid)
return
}
pageSize, ok := optionalPositiveInt(w, r, "page_size")
if !ok {
writeMallError(w, r, mall.ErrPageSizeInvalid)
return
}
categoryID, ok := optionalNonNegativeInt64(r.URL.Query().Get("category_id"))
if !ok {
writeMallError(w, r, mall.ErrCategoryIDInvalid)
return
}
result, err := a.mall.Products(r.Context(), principal.TenantID, categoryID, page, pageSize)
if err != nil {
writeMallError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func (a *API) manualProduct(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, r, http.MethodGet)
return
}
if a.mall == nil {
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
return
}
productIDText := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/manual-products/"), "/")
if productIDText == "" || strings.Contains(productIDText, "/") {
writeMallError(w, r, mall.ErrProductIDInvalid)
return
}
productID, err := strconv.ParseInt(productIDText, 10, 64)
if err != nil || productID <= 0 {
writeMallError(w, r, mall.ErrProductIDInvalid)
return
}
principal, ok := a.authenticateMember(w, r)
if !ok {
return
}
result, err := a.mall.Product(r.Context(), principal.TenantID, productID)
if err != nil {
writeMallError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func optionalNonNegativeInt64(value string) (int64, bool) {
value = strings.TrimSpace(value)
if value == "" {
return 0, true
}
parsed, err := strconv.ParseInt(value, 10, 64)
return parsed, err == nil && parsed >= 0
}
func writeMallError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, mall.ErrCategoryIDInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "CATEGORY_ID_INVALID", "validation failed", map[string]any{"field": "category_id"})
case errors.Is(err, mall.ErrProductIDInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "PRODUCT_ID_INVALID", "validation failed", map[string]any{"field": "product_id"})
case errors.Is(err, mall.ErrPageInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_INVALID", "validation failed", map[string]any{"field": "page"})
case errors.Is(err, mall.ErrPageSizeInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_SIZE_INVALID", "validation failed", map[string]any{"field": "page_size"})
case errors.Is(err, store.ErrResourceNotFound):
writeError(w, r, http.StatusNotFound, 30044, "PRODUCT_NOT_FOUND", "manual product not found", nil)
default:
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
}
}
+179
View File
@@ -0,0 +1,179 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"git.118im.com/im_team/im_workbench/internal/portal"
"git.118im.com/im_team/im_workbench/internal/store"
)
var publicContentKeys = map[string]struct{}{
"privacy_policy": {},
"user_agreement": {},
}
func (a *API) home(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
}
profile, err := a.accounts.Profile(r.Context(), principal.TenantID, principal.MemberID)
if err != nil {
writeProfileError(w, r, err)
return
}
result, err := a.portal.Home(r.Context(), principal.TenantID, principal.MemberID, profile)
if err != nil {
writePortalError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func (a *API) banners(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
}
position := strings.TrimSpace(r.URL.Query().Get("position"))
if position == "" {
position = "home_top"
}
items, err := a.portal.Banners(r.Context(), principal.TenantID, position)
if err != nil {
writePortalError(w, r, err)
return
}
writeSuccess(w, r, items)
}
func (a *API) content(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, r, http.MethodGet)
return
}
if !a.ensurePortal(w, r) {
return
}
if a.auth == nil {
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
return
}
key := strings.TrimPrefix(r.URL.Path, "/v1/contents/")
key = strings.ToLower(strings.TrimSpace(key))
if strings.Contains(key, "/") {
writePortalError(w, r, portal.ErrContentKeyInvalid)
return
}
tenantID := a.auth.DefaultTenantID()
if _, public := publicContentKeys[key]; !public {
principal, ok := a.authenticateMember(w, r)
if !ok {
return
}
tenantID = principal.TenantID
}
result, err := a.portal.Content(r.Context(), tenantID, key)
if err != nil {
writePortalError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func (a *API) memberLevels(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
}
items, err := a.portal.MemberLevels(r.Context(), principal.TenantID)
if err != nil {
writePortalError(w, r, err)
return
}
writeSuccess(w, r, items)
}
func (a *API) leaderboard(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
}
items, err := a.portal.Leaderboard(r.Context(), principal.TenantID)
if err != nil {
writePortalError(w, r, err)
return
}
writeSuccess(w, r, items)
}
func (a *API) customerServices(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
}
items, err := a.portal.CustomerServices(r.Context(), principal.TenantID)
if err != nil {
writePortalError(w, r, err)
return
}
writeSuccess(w, r, items)
}
func (a *API) ensurePortal(w http.ResponseWriter, r *http.Request) bool {
if a.portal != nil {
return true
}
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
return false
}
func writePortalError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, portal.ErrPositionInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "BANNER_POSITION_INVALID", "validation failed", map[string]any{"field": "position"})
case errors.Is(err, portal.ErrContentKeyInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "CONTENT_KEY_INVALID", "validation failed", map[string]any{"field": "content_key"})
case errors.Is(err, store.ErrResourceNotFound):
writeError(w, r, http.StatusNotFound, 30044, "RESOURCE_NOT_FOUND", "resource not found", nil)
default:
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
}
}
+160
View File
@@ -0,0 +1,160 @@
package httpapi
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"
)
type avatarRequest struct {
AttachmentID int64 `json:"attachment_id"`
}
type passwordRequest struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
type tradePasswordRequest struct {
LoginPassword string `json:"login_password"`
OldTradePassword string `json:"old_trade_password,omitempty"`
NewTradePassword string `json:"new_trade_password"`
}
func (a *API) memberProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, r, http.MethodGet)
return
}
principal, ok := a.authenticateMember(w, r)
if !ok {
return
}
profile, err := a.accounts.Profile(r.Context(), principal.TenantID, principal.MemberID)
if err != nil {
writeProfileError(w, r, err)
return
}
writeSuccess(w, r, profile)
}
func (a *API) memberAvatar(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch {
methodNotAllowed(w, r, http.MethodPatch)
return
}
principal, ok := a.authenticateMember(w, r)
if !ok {
return
}
var body avatarRequest
if !decodeBody(w, r, &body) {
return
}
avatar, err := a.accounts.ChangeAvatar(r.Context(), principal.TenantID, principal.MemberID, body.AttachmentID)
if err != nil {
writeProfileError(w, r, err)
return
}
writeSuccess(w, r, avatar)
}
func (a *API) memberPassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
methodNotAllowed(w, r, http.MethodPut)
return
}
principal, ok := a.authenticateMember(w, r)
if !ok {
return
}
var body passwordRequest
if !decodeBody(w, r, &body) {
return
}
err := a.accounts.ChangePassword(r.Context(), principal.TenantID, principal.MemberID, accountsvc.ChangePasswordInput{
OldPassword: body.OldPassword, NewPassword: body.NewPassword,
})
if err != nil {
writeProfileError(w, r, err)
return
}
if err := a.auth.RevokeMemberSessions(r.Context(), principal.TenantID, principal.MemberID); err != nil {
writeProfileError(w, r, err)
return
}
writeSuccess(w, r, nil)
}
func (a *API) memberTradePassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
methodNotAllowed(w, r, http.MethodPut)
return
}
principal, ok := a.authenticateMember(w, r)
if !ok {
return
}
var body tradePasswordRequest
if !decodeBody(w, r, &body) {
return
}
err := a.accounts.ChangeTradePassword(r.Context(), principal.TenantID, principal.MemberID, accountsvc.ChangeTradePasswordInput{
LoginPassword: body.LoginPassword, OldTradePassword: body.OldTradePassword,
NewTradePassword: body.NewTradePassword,
})
if err != nil {
writeProfileError(w, r, err)
return
}
writeSuccess(w, r, nil)
}
func (a *API) authenticateMember(w http.ResponseWriter, r *http.Request) (*auth.Principal, bool) {
if a.auth == nil || a.accounts == nil {
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
return nil, false
}
principal, err := a.auth.Authenticate(r)
if err == nil {
return principal, true
}
if errors.Is(err, auth.ErrForbidden) {
writeError(w, r, http.StatusForbidden, 30003, reasonForbidden, "forbidden", nil)
} else if errors.Is(err, auth.ErrAuthRequired) {
writeError(w, r, http.StatusUnauthorized, 30041, "AUTH_REQUIRED", "authentication required", nil)
} else {
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
}
return nil, false
}
func writeProfileError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, accountsvc.ErrAttachmentInvalid), errors.Is(err, store.ErrAttachmentInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "AVATAR_ATTACHMENT_INVALID", "validation failed", map[string]any{"field": "attachment_id"})
case errors.Is(err, accountsvc.ErrCurrentPassword):
writeError(w, r, http.StatusBadRequest, 30000, "CURRENT_PASSWORD_INVALID", "current password is invalid", map[string]any{"field": "old_password"})
case errors.Is(err, accountsvc.ErrLoginPassword):
writeError(w, r, http.StatusBadRequest, 30000, "LOGIN_PASSWORD_INVALID", "login password is invalid", map[string]any{"field": "login_password"})
case errors.Is(err, accountsvc.ErrPasswordInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "NEW_PASSWORD_INVALID", "validation failed", map[string]any{"field": "new_password"})
case errors.Is(err, accountsvc.ErrOldTradePasswordUnset):
writeError(w, r, http.StatusBadRequest, 30000, "OLD_TRADE_PASSWORD_REQUIRED", "old trade password is required", map[string]any{"field": "old_trade_password"})
case errors.Is(err, accountsvc.ErrOldTradePassword):
writeError(w, r, http.StatusBadRequest, 30000, "OLD_TRADE_PASSWORD_INVALID", "old trade password is invalid", map[string]any{"field": "old_trade_password"})
case errors.Is(err, accountsvc.ErrTradePassword):
writeError(w, r, http.StatusBadRequest, 30000, "TRADE_PASSWORD_INVALID", "trade password is invalid", map[string]any{"field": "new_trade_password"})
case errors.Is(err, store.ErrAccountNotFound):
writeError(w, r, http.StatusNotFound, 30044, "ACCOUNT_NOT_FOUND", "account not found", nil)
case errors.Is(err, store.ErrConcurrentUpdate):
writeError(w, r, http.StatusConflict, 30040, "ACCOUNT_CHANGED", "account changed, retry the operation", nil)
case errors.Is(err, accountsvc.ErrUnavailable):
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
default:
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
}
}
+68
View File
@@ -0,0 +1,68 @@
package httpapi
import (
"encoding/json"
"net/http"
"strings"
"time"
)
const (
reasonMalformedRequest = "MALFORMED_REQUEST"
reasonMethodNotAllowed = "METHOD_NOT_ALLOWED"
reasonForbidden = "FORBIDDEN"
reasonInternal = "INTERNAL"
)
type apiError struct {
Code int `json:"code"`
Reason string `json:"reason"`
Message string `json:"message"`
Details map[string]any `json:"details,omitempty"`
RequestID string `json:"request_id,omitempty"`
ServerTime string `json:"server_time"`
}
func writeSuccess(w http.ResponseWriter, r *http.Request, data any) {
writeJSON(w, r, http.StatusOK, map[string]any{
"code": 0, "data": data, "request_id": requestID(r),
"server_time": time.Now().UTC().Format(time.RFC3339Nano),
})
}
func writeError(w http.ResponseWriter, r *http.Request, status, code int, reason, message string, details map[string]any) {
writeJSON(w, r, status, apiError{Code: code, Reason: reason, Message: message, Details: details,
RequestID: requestID(r), ServerTime: time.Now().UTC().Format(time.RFC3339Nano)})
}
func writeJSON(w http.ResponseWriter, r *http.Request, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if requestID := requestID(r); requestID != "" {
w.Header().Set("X-Request-ID", requestID)
}
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func requestID(r *http.Request) string {
return r.Header.Get("X-Request-ID")
}
func decodeBody(w http.ResponseWriter, r *http.Request, dst any) bool {
defer r.Body.Close()
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(dst); err != nil {
writeError(w, r, http.StatusBadRequest, 30000, reasonMalformedRequest, "malformed request", nil)
return false
}
return true
}
func methodNotAllowed(w http.ResponseWriter, r *http.Request, allowed ...string) {
if len(allowed) == 0 {
allowed = []string{http.MethodPost}
}
w.Header().Set("Allow", strings.Join(allowed, ", "))
writeError(w, r, http.StatusMethodNotAllowed, 30005, reasonMethodNotAllowed, "method not allowed", nil)
}
+231
View File
@@ -0,0 +1,231 @@
package httpapi
import (
"errors"
"net/http"
"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"
)
type taskClaimRequest struct {
HallID int64 `json:"hall_id"`
}
func (a *API) taskHalls(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, r, http.MethodGet)
return
}
principal, ok := a.authenticateTaskMember(w, r)
if !ok {
return
}
result, err := a.tasks.Halls(r.Context(), principal.TenantID, principal.MemberID)
if err != nil {
writeTaskError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func (a *API) taskEligibility(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, r, http.MethodGet)
return
}
principal, ok := a.authenticateTaskMember(w, r)
if !ok {
return
}
hallID, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("hall_id")), 10, 64)
if err != nil {
writeTaskError(w, r, tasks.ErrHallIDInvalid)
return
}
result, err := a.tasks.Eligibility(r.Context(), principal.TenantID, principal.MemberID, hallID)
if err != nil {
writeTaskError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func (a *API) taskStats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, r, http.MethodGet)
return
}
principal, ok := a.authenticateTaskMember(w, r)
if !ok {
return
}
result, err := a.tasks.Stats(r.Context(), principal.TenantID, principal.MemberID)
if err != nil {
writeTaskError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func (a *API) taskClaim(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w, r, http.MethodPost)
return
}
principal, ok := a.authenticateTaskMember(w, r)
if !ok {
return
}
var body taskClaimRequest
if !decodeBody(w, r, &body) {
return
}
result, err := a.tasks.Claim(r.Context(), principal.TenantID, principal.MemberID, body.HallID)
if err != nil {
writeTaskError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func (a *API) taskOrders(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, r, http.MethodGet)
return
}
principal, ok := a.authenticateTaskMember(w, r)
if !ok {
return
}
page, ok := optionalPositiveInt(w, r, "page")
if !ok {
writeTaskError(w, r, tasks.ErrPageInvalid)
return
}
pageSize, ok := optionalPositiveInt(w, r, "page_size")
if !ok {
writeTaskError(w, r, tasks.ErrPageSizeInvalid)
return
}
result, err := a.tasks.Orders(r.Context(), principal.TenantID, principal.MemberID, page, pageSize, r.URL.Query().Get("status"))
if err != nil {
writeTaskError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func (a *API) taskOrderResource(w http.ResponseWriter, r *http.Request) {
path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/task-orders/"), "/")
parts := strings.Split(path, "/")
if len(parts) < 1 || len(parts) > 2 || parts[0] == "" {
writeTaskError(w, r, tasks.ErrOrderIDInvalid)
return
}
orderID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || orderID <= 0 {
writeTaskError(w, r, tasks.ErrOrderIDInvalid)
return
}
principal, ok := a.authenticateTaskMember(w, r)
if !ok {
return
}
if len(parts) == 1 {
if r.Method != http.MethodGet {
methodNotAllowed(w, r, http.MethodGet)
return
}
result, err := a.tasks.Order(r.Context(), principal.TenantID, principal.MemberID, orderID)
if err != nil {
writeTaskError(w, r, err)
return
}
writeSuccess(w, r, result)
return
}
if parts[1] != "submissions" {
writeTaskError(w, r, store.ErrOrderNotFound)
return
}
if r.Method != http.MethodPost {
methodNotAllowed(w, r, http.MethodPost)
return
}
var body tasks.SubmitInput
if !decodeBody(w, r, &body) {
return
}
result, err := a.tasks.Submit(r.Context(), principal.TenantID, principal.MemberID, orderID, body)
if err != nil {
writeTaskError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func (a *API) authenticateTaskMember(w http.ResponseWriter, r *http.Request) (*auth.Principal, bool) {
if a.tasks == nil {
writeError(w, r, http.StatusServiceUnavailable, 90001, "SERVICE_UNAVAILABLE", "service unavailable", nil)
return nil, false
}
principal, ok := a.authenticateMember(w, r)
if !ok {
return nil, false
}
return principal, true
}
func optionalPositiveInt(w http.ResponseWriter, r *http.Request, name string) (int, bool) {
value := strings.TrimSpace(r.URL.Query().Get(name))
if value == "" {
return 0, true
}
parsed, err := strconv.Atoi(value)
return parsed, err == nil && parsed > 0
}
func writeTaskError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, tasks.ErrHallIDInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "HALL_ID_INVALID", "validation failed", map[string]any{"field": "hall_id"})
case errors.Is(err, tasks.ErrOrderIDInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "ORDER_ID_INVALID", "validation failed", map[string]any{"field": "order_id"})
case errors.Is(err, tasks.ErrPageInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_INVALID", "validation failed", map[string]any{"field": "page"})
case errors.Is(err, tasks.ErrPageSizeInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_SIZE_INVALID", "validation failed", map[string]any{"field": "page_size"})
case errors.Is(err, tasks.ErrStatusInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "ORDER_STATUS_INVALID", "validation failed", map[string]any{"field": "status"})
case errors.Is(err, tasks.ErrEvidenceInvalid), errors.Is(err, store.ErrEvidenceInvalid):
writeError(w, r, http.StatusBadRequest, 30000, "EVIDENCE_INVALID", "validation failed", map[string]any{"field": "evidence"})
case errors.Is(err, tasks.ErrTaskDisabled), errors.Is(err, store.ErrTaskDisabled):
writeError(w, r, http.StatusUnprocessableEntity, 30022, "TASK_DISABLED", "task feature is disabled", nil)
case errors.Is(err, tasks.ErrOutsideBusinessHour), errors.Is(err, store.ErrOutsideBusinessHours):
writeError(w, r, http.StatusUnprocessableEntity, 30022, "OUTSIDE_BUSINESS_HOURS", "outside business hours", nil)
case errors.Is(err, store.ErrInsufficientBalance):
writeError(w, r, http.StatusUnprocessableEntity, 30022, "INSUFFICIENT_BALANCE", "insufficient available balance", nil)
case errors.Is(err, store.ErrDailyLimitReached):
writeError(w, r, http.StatusUnprocessableEntity, 30022, "DAILY_LIMIT_REACHED", "daily claim limit reached", nil)
case errors.Is(err, store.ErrLevelNotEligible), errors.Is(err, store.ErrTaskHallNotFound):
writeError(w, r, http.StatusUnprocessableEntity, 30022, "LEVEL_NOT_ELIGIBLE", "member level is not eligible", nil)
case errors.Is(err, store.ErrAccountNotFound), errors.Is(err, store.ErrAccountRestricted):
writeError(w, r, http.StatusUnprocessableEntity, 30022, "ACCOUNT_RESTRICTED", "account is restricted", nil)
case errors.Is(err, store.ErrPendingOrderExists):
writeError(w, r, http.StatusConflict, 30040, "PENDING_ORDER_EXISTS", "unfinished order exists", nil)
case errors.Is(err, store.ErrNoTaskAvailable):
writeError(w, r, http.StatusServiceUnavailable, 30053, "NO_TASK_AVAILABLE", "no task is currently available", nil)
case errors.Is(err, store.ErrOrderNotFound):
writeError(w, r, http.StatusNotFound, 30044, "ORDER_NOT_FOUND", "task order not found", nil)
case errors.Is(err, store.ErrOrderState):
writeError(w, r, http.StatusConflict, 30040, "ORDER_STATE_INVALID", "task order state does not allow this operation", nil)
case errors.Is(err, store.ErrOrderExpired):
writeError(w, r, http.StatusConflict, 30040, "ORDER_EXPIRED", "task order has expired", nil)
default:
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
}
}
+10
View File
@@ -0,0 +1,10 @@
package mall
import "errors"
var (
ErrCategoryIDInvalid = errors.New("category id invalid")
ErrProductIDInvalid = errors.New("product id invalid")
ErrPageInvalid = errors.New("page invalid")
ErrPageSizeInvalid = errors.New("page size invalid")
)
+153
View File
@@ -0,0 +1,153 @@
package mall
import (
"context"
"html"
"net/url"
"regexp"
"strings"
"unicode/utf8"
"git.118im.com/im_team/im_workbench/internal/store"
)
const (
defaultPageSize = 20
maxPageSize = 100
maxSummaryRunes = 120
)
var (
scriptBlockPattern = regexp.MustCompile(`(?is)<script\b[^>]*>.*?</script\s*>`)
styleBlockPattern = regexp.MustCompile(`(?is)<style\b[^>]*>.*?</style\s*>`)
htmlTagPattern = regexp.MustCompile(`(?s)<[^>]*>`)
)
type Repository interface {
ManualProducts(context.Context, int64, int64, int, int) (*store.ManualProductPage, error)
ManualProduct(context.Context, int64, int64) (*store.ManualProduct, error)
}
type Service struct {
repository Repository
}
type Product struct {
ID int64 `json:"id"`
ProductNo string `json:"product_no"`
Category Category `json:"category"`
Name string `json:"name"`
ShopName string `json:"shop_name"`
PriceAmount int64 `json:"price_amount"`
Currency string `json:"currency"`
CoverURL string `json:"cover_url"`
Summary string `json:"summary"`
ClaimedCount int64 `json:"claimed_count"`
IsRecommended bool `json:"is_recommended"`
IsLimited bool `json:"is_limited"`
StartAt string `json:"start_at"`
EndAt string `json:"end_at"`
AvailableActions []string `json:"available_actions"`
PaymentType string `json:"payment_type,omitempty"`
DetailText string `json:"detail_text,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type Category struct {
ID int64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
}
type ProductPage struct {
Items []Product `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
}
func New(repository Repository) *Service {
return &Service{repository: repository}
}
func (s *Service) Products(ctx context.Context, tenantID, categoryID int64, page, pageSize int) (*ProductPage, error) {
if categoryID < 0 {
return nil, ErrCategoryIDInvalid
}
if page == 0 {
page = 1
}
if pageSize == 0 {
pageSize = defaultPageSize
}
if page < 1 {
return nil, ErrPageInvalid
}
if pageSize < 1 || pageSize > maxPageSize {
return nil, ErrPageSizeInvalid
}
rows, err := s.repository.ManualProducts(ctx, tenantID, categoryID, (page-1)*pageSize, pageSize)
if err != nil {
return nil, err
}
items := make([]Product, 0, len(rows.Items))
for _, row := range rows.Items {
items = append(items, productFromStore(row, false))
}
return &ProductPage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil
}
func (s *Service) Product(ctx context.Context, tenantID, productID int64) (*Product, error) {
if productID <= 0 {
return nil, ErrProductIDInvalid
}
row, err := s.repository.ManualProduct(ctx, tenantID, productID)
if err != nil {
return nil, err
}
result := productFromStore(*row, true)
return &result, nil
}
func productFromStore(row store.ManualProduct, detail bool) Product {
detailText := plainText(row.DetailHTML)
result := Product{ID: row.ID, ProductNo: row.ProductNo,
Category: Category{ID: row.CategoryID, Code: row.CategoryCode, Name: row.CategoryName},
Name: row.Name, ShopName: row.ShopName, PriceAmount: row.PriceAmount, Currency: row.Currency,
CoverURL: safeHTTPSURL(row.CoverURL), Summary: truncate(detailText, maxSummaryRunes),
ClaimedCount: row.ClaimedCount, IsRecommended: row.IsRecommended, IsLimited: row.EndAt != "",
StartAt: row.StartAt, EndAt: row.EndAt,
AvailableActions: []string{"contact_support"}}
if detail {
result.PaymentType = row.PaymentType
result.DetailText = detailText
result.CreatedAt = row.CreatedAt
result.UpdatedAt = row.UpdatedAt
}
return result
}
func plainText(raw string) string {
raw = scriptBlockPattern.ReplaceAllString(raw, " ")
raw = styleBlockPattern.ReplaceAllString(raw, " ")
withoutTags := htmlTagPattern.ReplaceAllString(raw, " ")
return strings.Join(strings.Fields(html.UnescapeString(withoutTags)), " ")
}
func truncate(value string, limit int) string {
if utf8.RuneCountInString(value) <= limit {
return value
}
runes := []rune(value)
return string(runes[:limit]) + "…"
}
func safeHTTPSURL(raw string) string {
raw = strings.TrimSpace(raw)
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
return ""
}
return parsed.String()
}
+63
View File
@@ -0,0 +1,63 @@
package mall
import (
"context"
"errors"
"testing"
"git.118im.com/im_team/im_workbench/internal/store"
)
type fakeRepository struct {
items []store.ManualProduct
detail *store.ManualProduct
}
func (f *fakeRepository) ManualProducts(context.Context, int64, int64, int, int) (*store.ManualProductPage, error) {
return &store.ManualProductPage{Items: f.items, Total: int64(len(f.items))}, nil
}
func (f *fakeRepository) ManualProduct(context.Context, int64, int64) (*store.ManualProduct, error) {
return f.detail, nil
}
func TestProductsMapsSafeCard(t *testing.T) {
service := New(&fakeRepository{items: []store.ManualProduct{{ID: 4, ProductNo: "DEMO-P-03",
CategoryID: 2, CategoryCode: "DAILY", CategoryName: "日用", Name: "体验商品", ShopName: "平台",
PriceAmount: 12900, Currency: "CNY", CoverURL: "https://example.com/cover.png",
DetailHTML: "<h3>标题</h3><script>alert(1)</script><p>内容</p>", ClaimedCount: 2, IsRecommended: true,
StartAt: "2026-10-01T00:00:00Z", EndAt: "2026-10-31T00:00:00Z"}}})
result, err := service.Products(context.Background(), 0, 0, 1, 20)
if err != nil {
t.Fatal(err)
}
if len(result.Items) != 1 || result.Items[0].CoverURL == "" || result.Items[0].Summary != "标题 内容" ||
!result.Items[0].IsLimited || result.Items[0].EndAt == "" || len(result.Items[0].AvailableActions) != 1 ||
result.Items[0].AvailableActions[0] != "contact_support" {
t.Fatalf("unexpected product: %#v", result.Items)
}
}
func TestProductRejectsUnsafeCoverAndReturnsPlainText(t *testing.T) {
service := New(&fakeRepository{detail: &store.ManualProduct{ID: 4, Name: "商品",
CoverURL: "javascript:alert(1)", DetailHTML: "<p>安全说明</p>"}})
result, err := service.Product(context.Background(), 0, 4)
if err != nil {
t.Fatal(err)
}
if result.CoverURL != "" || result.DetailText != "安全说明" {
t.Fatalf("unexpected detail: %#v", result)
}
}
func TestProductsValidatesPagination(t *testing.T) {
service := New(&fakeRepository{})
if _, err := service.Products(context.Background(), 0, 0, -1, 20); !errors.Is(err, ErrPageInvalid) {
t.Fatalf("expected page error, got %v", err)
}
if _, err := service.Products(context.Background(), 0, -1, 1, 20); !errors.Is(err, ErrCategoryIDInvalid) {
t.Fatalf("expected category error, got %v", err)
}
}
+8
View File
@@ -0,0 +1,8 @@
package portal
import "errors"
var (
ErrPositionInvalid = errors.New("banner position invalid")
ErrContentKeyInvalid = errors.New("content key invalid")
)
+339
View File
@@ -0,0 +1,339 @@
package portal
import (
"context"
"encoding/json"
"net/url"
"regexp"
"strings"
"git.118im.com/im_team/im_workbench/internal/accounts"
"git.118im.com/im_team/im_workbench/internal/store"
)
var (
positionPattern = regexp.MustCompile(`^[a-z0-9_-]{1,32}$`)
contentKeyPattern = regexp.MustCompile(`^[a-z0-9_-]{1,64}$`)
)
type Repository interface {
CurrentSiteConfig(context.Context, int64) (json.RawMessage, error)
ActiveBanners(context.Context, int64, string) ([]store.Banner, error)
PublishedContent(context.Context, int64, string) (*store.Content, error)
ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error)
ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error)
MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error)
TaskLeaderboard(context.Context, int64, int) ([]store.LeaderboardMember, error)
ActiveCustomerServices(context.Context, int64) ([]store.CustomerService, error)
}
type Service struct {
repository Repository
}
type Features struct {
TaskEnabled bool `json:"task_enabled"`
RechargeEnabled bool `json:"recharge_enabled"`
WithdrawalEnabled bool `json:"withdrawal_enabled"`
ManualMallEnabled bool `json:"manual_mall_enabled"`
}
type Banner struct {
ID int64 `json:"id"`
ImageURL string `json:"image_url"`
AltText string `json:"alt_text"`
TargetType string `json:"target_type"`
TargetValue string `json:"target_value,omitempty"`
}
type Notice struct {
ID int64 `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
SourceType string `json:"source_type"`
StartAt string `json:"start_at"`
EndAt string `json:"end_at"`
}
type Home struct {
Profile HomeProfile `json:"profile"`
Wallet accounts.ProfileWallet `json:"wallet"`
Features Features `json:"features"`
Banners []Banner `json:"banners"`
Notices []Notice `json:"notices"`
TaskSummary store.TaskSummary `json:"task_summary"`
}
type HomeProfile struct {
Username string `json:"username"`
AvatarURL string `json:"avatar_url"`
LevelName string `json:"level_name"`
}
type Content struct {
ContentKey string `json:"content_key"`
ContentType string `json:"content_type"`
Title string `json:"title"`
Summary string `json:"summary"`
BodyHTML string `json:"body_html"`
BodyText string `json:"body_text"`
CoverURL string `json:"cover_url"`
AssetIDs []int64 `json:"asset_ids"`
Version int `json:"version"`
PublishedAt string `json:"published_at"`
}
type MemberLevel struct {
ID int64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
IconURL string `json:"icon_url"`
UpgradeAmount int64 `json:"upgrade_amount"`
GiftAmount int64 `json:"gift_amount"`
DailyOrderLimit int `json:"daily_order_limit"`
CommissionRate int `json:"commission_rate"`
DailyWithdrawLimit int `json:"daily_withdraw_limit"`
Rules json.RawMessage `json:"rules"`
Version int `json:"version"`
}
type LeaderboardEntry struct {
Rank int `json:"rank"`
Username string `json:"username"`
AvatarURL string `json:"avatar_url"`
CompletedCount int64 `json:"completed_count"`
Status string `json:"status"`
}
type CustomerService struct {
ID int64 `json:"id"`
ServiceCode string `json:"service_code"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
WorkTime string `json:"work_time"`
OnlineStatus string `json:"online_status"`
ServiceURL string `json:"service_url"`
BackupContact string `json:"backup_contact"`
PrivacyNotice string `json:"privacy_notice"`
}
func New(repository Repository) *Service {
return &Service{repository: repository}
}
func (s *Service) Home(ctx context.Context, tenantID, memberID int64, profile *accounts.Profile) (*Home, error) {
config, err := s.repository.CurrentSiteConfig(ctx, tenantID)
if err != nil {
return nil, err
}
banners, err := s.Banners(ctx, tenantID, "home_top")
if err != nil {
return nil, err
}
noticeRows, err := s.repository.ActiveMemberNotices(ctx, tenantID, memberID, 5)
if err != nil {
return nil, err
}
summary, err := s.repository.MemberTaskSummary(ctx, tenantID, memberID)
if err != nil {
return nil, err
}
notices := make([]Notice, 0, len(noticeRows))
for _, row := range noticeRows {
notices = append(notices, Notice{ID: row.ID, Title: row.Title, Content: row.Content,
SourceType: noticeSourceName(row.SourceType), StartAt: row.StartAt, EndAt: row.EndAt})
}
return &Home{
Profile: HomeProfile{Username: profile.Username, AvatarURL: profile.AvatarURL, LevelName: profile.Level.Name},
Wallet: profile.Wallet, Features: parseFeatures(config), Banners: banners, Notices: notices,
TaskSummary: *summary,
}, nil
}
func (s *Service) Banners(ctx context.Context, tenantID int64, position string) ([]Banner, error) {
position = strings.ToLower(strings.TrimSpace(position))
if !positionPattern.MatchString(position) {
return nil, ErrPositionInvalid
}
rows, err := s.repository.ActiveBanners(ctx, tenantID, position)
if err != nil {
return nil, err
}
items := make([]Banner, 0, len(rows))
for _, row := range rows {
targetType, targetValue := bannerTarget(row.TargetType, row.TargetValue)
items = append(items, Banner{ID: row.ID, ImageURL: row.ImageURL, AltText: row.AltText,
TargetType: targetType, TargetValue: targetValue})
}
return items, nil
}
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
}
row, err := s.repository.PublishedContent(ctx, tenantID, key)
if err != nil {
return nil, err
}
assetIDs := make([]int64, 0)
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,
Summary: row.Summary, BodyHTML: row.BodyHTML, BodyText: row.BodyText, CoverURL: row.CoverURL,
AssetIDs: assetIDs, Version: row.Version, PublishedAt: row.PublishedAt}, nil
}
func (s *Service) MemberLevels(ctx context.Context, tenantID int64) ([]MemberLevel, error) {
rows, err := s.repository.ActiveMemberLevels(ctx, tenantID)
if err != nil {
return nil, err
}
items := make([]MemberLevel, 0, len(rows))
for _, row := range rows {
rules := row.RuleJSON
if len(rules) == 0 {
rules = json.RawMessage(`{}`)
}
items = append(items, MemberLevel{ID: row.ID, Code: row.Code, Name: row.Name, IconURL: row.IconURL,
UpgradeAmount: row.UpgradeAmount, GiftAmount: row.GiftAmount, DailyOrderLimit: row.DailyOrderLimit,
CommissionRate: row.CommissionRate, DailyWithdrawLimit: row.DailyWithdrawLimit,
Rules: rules, Version: row.Version})
}
return items, nil
}
func (s *Service) Leaderboard(ctx context.Context, tenantID int64) ([]LeaderboardEntry, error) {
rows, err := s.repository.TaskLeaderboard(ctx, tenantID, 50)
if err != nil {
return nil, err
}
items := make([]LeaderboardEntry, 0, len(rows))
for index, row := range rows {
items = append(items, LeaderboardEntry{Rank: index + 1, Username: maskedUsername(row.Username),
AvatarURL: row.AvatarURL, CompletedCount: row.CompletedCount, Status: "领取成功"})
}
return items, nil
}
func (s *Service) CustomerServices(ctx context.Context, tenantID int64) ([]CustomerService, error) {
rows, err := s.repository.ActiveCustomerServices(ctx, tenantID)
if err != nil {
return nil, err
}
items := make([]CustomerService, 0, len(rows))
for _, row := range rows {
items = append(items, CustomerService{
ID: row.ID, ServiceCode: row.ServiceCode, Name: row.Name,
AvatarURL: safeExternalURL(row.AvatarURL), WorkTime: row.WorkTime,
OnlineStatus: customerServiceStatus(row.OnlineStatus), ServiceURL: safeExternalURL(row.ServiceURL),
BackupContact: row.BackupContact,
PrivacyNotice: "请勿在客服会话中发送登录密码、支付密码或短信验证码",
})
}
return items, nil
}
func maskedUsername(value string) string {
runes := []rune(strings.TrimSpace(value))
switch len(runes) {
case 0:
return "会员"
case 1:
return string(runes[0]) + "*"
case 2:
return string(runes[0]) + "*"
default:
return string(runes[0]) + "***" + string(runes[len(runes)-1])
}
}
func parseFeatures(raw json.RawMessage) Features {
var config map[string]any
if json.Unmarshal(raw, &config) != nil {
return Features{}
}
return Features{
TaskEnabled: findBool(config, "task_enabled", "taskEnabled", "TASK"),
RechargeEnabled: findBool(config, "recharge_enabled", "rechargeEnabled", "RECHARGE"),
WithdrawalEnabled: findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"),
ManualMallEnabled: findBool(config, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"),
}
}
func findBool(config map[string]any, keys ...string) bool {
for _, key := range keys {
if value, ok := config[key]; ok {
return boolValue(value)
}
}
if nested, ok := config["features"].(map[string]any); ok {
for _, key := range keys {
if value, found := nested[key]; found {
return boolValue(value)
}
}
}
return false
}
func boolValue(value any) bool {
switch typed := value.(type) {
case bool:
return typed
case float64:
return typed == 1
case string:
return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on")
default:
return false
}
}
func bannerTarget(kind int, value string) (string, string) {
value = strings.TrimSpace(value)
switch kind {
case 2:
if strings.HasPrefix(value, "/") && !strings.HasPrefix(value, "//") {
return "internal_route", value
}
case 3:
parsed, err := url.Parse(value)
if err == nil && parsed.Scheme == "https" && parsed.Host != "" {
return "external_url", value
}
}
return "none", ""
}
func safeExternalURL(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
return ""
}
return parsed.String()
}
func customerServiceStatus(status int) string {
switch status {
case 1:
return "online"
case 3:
return "busy"
default:
return "offline"
}
}
func noticeSourceName(source int) string {
if source == 2 {
return "operational_simulation"
}
return "real"
}
+166
View File
@@ -0,0 +1,166 @@
package portal
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"testing"
"git.118im.com/im_team/im_workbench/internal/accounts"
"git.118im.com/im_team/im_workbench/internal/store"
)
type fakeRepository struct {
config json.RawMessage
banners []store.Banner
content *store.Content
levels []store.MemberLevel
notices []store.MemberNotice
summary *store.TaskSummary
leaders []store.LeaderboardMember
services []store.CustomerService
}
func (r fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) {
return r.config, nil
}
func (r fakeRepository) ActiveBanners(context.Context, int64, string) ([]store.Banner, error) {
return r.banners, nil
}
func (r fakeRepository) PublishedContent(context.Context, int64, string) (*store.Content, error) {
if r.content == nil {
return nil, store.ErrResourceNotFound
}
return r.content, nil
}
func (r fakeRepository) ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error) {
return r.levels, nil
}
func (r fakeRepository) ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error) {
return r.notices, nil
}
func (r fakeRepository) MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error) {
return r.summary, nil
}
func (r fakeRepository) TaskLeaderboard(context.Context, int64, int) ([]store.LeaderboardMember, error) {
return r.leaders, nil
}
func (r fakeRepository) ActiveCustomerServices(context.Context, int64) ([]store.CustomerService, error) {
return r.services, nil
}
func TestParseFeaturesSupportsNestedAndLegacyKeys(t *testing.T) {
features := parseFeatures(json.RawMessage(`{
"features":{"task_enabled":true,"rechargeEnabled":1},
"WITHDRAWAL":"on","SHOP_STATUS":"false"
}`))
if !features.TaskEnabled || !features.RechargeEnabled || !features.WithdrawalEnabled || features.ManualMallEnabled {
t.Fatalf("unexpected features: %+v", features)
}
}
func TestBannersSanitizeTargets(t *testing.T) {
service := New(fakeRepository{banners: []store.Banner{
{ID: 1, TargetType: 2, TargetValue: "/receive"},
{ID: 2, TargetType: 2, TargetValue: "//evil.example"},
{ID: 3, TargetType: 3, TargetValue: "https://example.com/path"},
{ID: 4, TargetType: 3, TargetValue: "javascript:alert(1)"},
}})
items, err := service.Banners(context.Background(), 0, "home_top")
if err != nil {
t.Fatal(err)
}
wantTypes := []string{"internal_route", "none", "external_url", "none"}
for i, want := range wantTypes {
if items[i].TargetType != want {
t.Errorf("item %d target type = %q, want %q", i, items[i].TargetType, want)
}
}
}
func TestBannersRejectInvalidPosition(t *testing.T) {
service := New(fakeRepository{})
if _, err := service.Banners(context.Background(), 0, "../home"); !errors.Is(err, ErrPositionInvalid) {
t.Fatalf("error = %v, want %v", err, ErrPositionInvalid)
}
}
func TestContentMapsAssets(t *testing.T) {
service := New(fakeRepository{content: &store.Content{
ContentKey: "privacy_policy", Title: "Policy", AssetIDs: sql.NullString{String: `[1,2]`, Valid: true},
}})
content, err := service.Content(context.Background(), 0, "privacy_policy")
if err != nil {
t.Fatal(err)
}
if len(content.AssetIDs) != 2 || content.AssetIDs[0] != 1 || content.AssetIDs[1] != 2 {
t.Fatalf("unexpected asset IDs: %v", content.AssetIDs)
}
}
func TestMemberLevelsAlwaysReturnJSONObjectRules(t *testing.T) {
service := New(fakeRepository{levels: []store.MemberLevel{{ID: 1, Code: "VIP1", Name: "Normal"}}})
levels, err := service.MemberLevels(context.Background(), 0)
if err != nil {
t.Fatal(err)
}
if string(levels[0].Rules) != `{}` {
t.Fatalf("rules = %s, want {}", levels[0].Rules)
}
}
func TestHomeUsesSettledSummaryAndSnakeCaseJSON(t *testing.T) {
service := New(fakeRepository{
config: json.RawMessage(`{"features":{"task_enabled":true}}`),
summary: &store.TaskSummary{TodayCount: 2, TodayCommissionAmount: 150},
})
home, err := service.Home(context.Background(), 0, 7, &accounts.Profile{
Username: "alice", Level: accounts.ProfileLevel{Name: "VIP1"},
Wallet: accounts.ProfileWallet{Currency: "CNY"},
})
if err != nil {
t.Fatal(err)
}
encoded, err := json.Marshal(home)
if err != nil {
t.Fatal(err)
}
text := string(encoded)
for _, field := range []string{`"today_count":2`, `"today_commission_amount":150`, `"task_enabled":true`} {
if !strings.Contains(text, field) {
t.Errorf("home JSON %s does not contain %s", text, field)
}
}
}
func TestLeaderboardMasksUsernameAndAddsRank(t *testing.T) {
service := New(fakeRepository{leaders: []store.LeaderboardMember{
{MemberID: 7, Username: "风轻云淡", CompletedCount: 12},
{MemberID: 8, Username: "李明", CompletedCount: 9},
}})
items, err := service.Leaderboard(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
if len(items) != 2 || items[0].Rank != 1 || items[0].Username != "风***淡" ||
items[1].Username != "李*" || items[1].Status != "领取成功" {
t.Fatalf("unexpected leaderboard: %+v", items)
}
}
func TestCustomerServicesSanitizeURLsAndMapStatus(t *testing.T) {
service := New(fakeRepository{services: []store.CustomerService{
{ID: 1, Name: "在线客服", AvatarURL: "https://example.com/avatar.png", ServiceURL: "https://example.com/chat", OnlineStatus: 1},
{ID: 2, Name: "离线客服", AvatarURL: "javascript:alert(1)", ServiceURL: "http://example.com/chat", OnlineStatus: 2},
}})
items, err := service.CustomerServices(context.Background(), 0)
if err != nil {
t.Fatal(err)
}
if len(items) != 2 || items[0].OnlineStatus != "online" || items[0].ServiceURL == "" ||
items[1].OnlineStatus != "offline" || items[1].AvatarURL != "" || items[1].ServiceURL != "" {
t.Fatalf("unexpected customer services: %+v", items)
}
}
+172
View File
@@ -0,0 +1,172 @@
package store
import (
"context"
"database/sql"
"errors"
"strconv"
"strings"
"time"
"github.com/go-sql-driver/mysql"
"github.com/jmoiron/sqlx"
)
const (
memberStatusActive = 1
memberCategoryReal = 1
)
type AccountRegistration struct {
TenantID int64
MemberNo string
Username string
Mobile string
PasswordHash string
ReferralCode string
InviteCode string
}
type Account struct {
TenantID int64 `db:"tenant_id"`
MemberID int64 `db:"member_id"`
MemberNo string `db:"member_no"`
Username string `db:"username"`
Mobile string `db:"mobile"`
PasswordHash string `db:"password_hash"`
InviteCode string `db:"invite_code"`
MemberStatus int `db:"member_status"`
MemberCategory int `db:"member_category"`
}
func (s *Store) RegisterAccount(ctx context.Context, input AccountRegistration) (*Account, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
parentID, teamLevel, tree, err := referralParent(ctx, tx, input.TenantID, input.ReferralCode)
if err != nil {
return nil, err
}
levelID, err := defaultMemberLevel(ctx, tx, input.TenantID)
if err != nil {
return nil, err
}
result, err := tx.ExecContext(ctx, `INSERT INTO hg_app_member
(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)`,
input.TenantID, input.MemberNo, input.Username, input.Mobile, input.PasswordHash,
levelID, parentID, teamLevel, tree, input.InviteCode)
if err != nil {
if isDuplicate(err) {
return nil, ErrAccountExists
}
return nil, err
}
memberID, err := result.LastInsertId()
if err != nil {
return nil, err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO hg_app_member_profile
(tenant_id,member_id,created_by,updated_by) VALUES(?,?,0,0)`, input.TenantID, memberID); err != nil {
return nil, err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO hg_wallet_account
(tenant_id,member_id,currency,available_amount,frozen_amount,total_income,total_expense,account_status,version)
VALUES(?,?,'CNY',0,0,0,0,1,0)`, input.TenantID, memberID); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return &Account{TenantID: input.TenantID, MemberID: memberID, MemberNo: input.MemberNo,
Username: input.Username, Mobile: input.Mobile,
InviteCode: input.InviteCode, MemberStatus: memberStatusActive, MemberCategory: memberCategoryReal}, nil
}
func (s *Store) AccountByMobile(ctx context.Context, tenantID int64, mobile string) (*Account, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
return accountByMobile(ctx, s.db, tenantID, mobile)
}
func (s *Store) RecordLogin(ctx context.Context, tenantID, memberID int64) error {
_, err := s.db.ExecContext(ctx, `UPDATE hg_app_member SET last_login_at=?,updated_at=?
WHERE tenant_id=? AND id=? AND deleted_at IS NULL`, time.Now().UTC(), time.Now().UTC(), tenantID, memberID)
return err
}
type queryer interface {
GetContext(context.Context, any, string, ...any) error
}
func accountByMobile(ctx context.Context, db queryer, tenantID int64, mobile string) (*Account, error) {
var row Account
err := db.GetContext(ctx, &row, `SELECT tenant_id, id AS member_id, member_no, username, mobile,
password_hash, invite_code, member_status, risk_status AS member_category
FROM hg_app_member WHERE tenant_id=? AND mobile=? AND deleted_at IS NULL LIMIT 1`, tenantID, mobile)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrInvalidCredentials
}
if err != nil {
return nil, err
}
if row.MemberStatus != memberStatusActive {
return nil, ErrInvalidCredentials
}
return &row, nil
}
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
}
var parent struct {
ID int64 `db:"id"`
Level int `db:"level"`
Tree string `db:"tree"`
}
err := tx.GetContext(ctx, &parent, `SELECT id,level,tree FROM hg_app_member
WHERE tenant_id=? AND invite_code=? AND member_status=1
AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, tenantID, code)
if errors.Is(err, sql.ErrNoRows) {
return 0, 0, "", ErrReferralInvalid
}
if err != nil {
return 0, 0, "", err
}
tree := strings.Trim(strings.TrimSpace(parent.Tree)+","+formatInt64(parent.ID), ",")
if len(tree) > 512 {
return 0, 0, "", ErrReferralInvalid
}
return parent.ID, parent.Level + 1, tree, nil
}
func defaultMemberLevel(ctx context.Context, tx *sqlx.Tx, tenantID int64) (int64, error) {
var id int64
err := tx.GetContext(ctx, &id, `SELECT id FROM hg_app_member_level
WHERE tenant_id=? AND status=1 AND deleted_at IS NULL ORDER BY sort ASC,id ASC LIMIT 1`, tenantID)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
return id, err
}
func isDuplicate(err error) bool {
var mysqlErr *mysql.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
}
func formatInt64(value int64) string {
return strconv.FormatInt(value, 10)
}
+38
View File
@@ -0,0 +1,38 @@
package store
import "errors"
var (
ErrAccountExists = errors.New("account already exists")
ErrAccountNotFound = errors.New("account not found")
ErrAccountRestricted = errors.New("account restricted")
ErrAttachmentInvalid = errors.New("attachment invalid")
ErrConcurrentUpdate = errors.New("concurrent update")
ErrInvalidCredentials = errors.New("invalid credentials")
ErrReferralInvalid = errors.New("referral code invalid")
ErrResourceNotFound = errors.New("resource not found")
ErrTaskHallNotFound = errors.New("task hall not found")
ErrTaskDisabled = errors.New("task feature disabled")
ErrOutsideBusinessHours = errors.New("outside business hours")
ErrInsufficientBalance = errors.New("insufficient balance")
ErrDailyLimitReached = errors.New("daily limit reached")
ErrLevelNotEligible = errors.New("member level not eligible")
ErrPendingOrderExists = errors.New("pending order exists")
ErrPaymentAccountExists = errors.New("payment account already exists")
ErrPaymentAccountInvalid = errors.New("payment account invalid")
ErrWithdrawalDisabled = errors.New("withdrawal disabled")
ErrWithdrawalChannel = errors.New("withdrawal channel unavailable")
ErrWithdrawalAmount = errors.New("withdrawal amount invalid")
ErrWithdrawalLimit = errors.New("withdrawal daily limit reached")
ErrWithdrawalNotFound = errors.New("withdrawal order not found")
ErrRechargeChannel = errors.New("recharge channel unavailable")
ErrRechargeAmount = errors.New("recharge amount invalid")
ErrRechargeNotFound = errors.New("recharge order not found")
ErrRechargeVoucher = errors.New("recharge voucher invalid")
ErrIdempotencyConflict = errors.New("idempotency key reused with different request")
ErrNoTaskAvailable = errors.New("no task available")
ErrOrderNotFound = errors.New("task order not found")
ErrOrderState = errors.New("task order state invalid")
ErrOrderExpired = errors.New("task order expired")
ErrEvidenceInvalid = errors.New("task evidence invalid")
)
+82
View File
@@ -0,0 +1,82 @@
package store
import (
"context"
"database/sql"
"errors"
)
type ManualProduct struct {
ID int64 `db:"id"`
ProductNo string `db:"product_no"`
CategoryID int64 `db:"category_id"`
CategoryCode string `db:"category_code"`
CategoryName string `db:"category_name"`
PaymentType string `db:"payment_type"`
Name string `db:"name"`
ShopName string `db:"shop_name"`
PriceAmount int64 `db:"price_amount"`
Currency string `db:"currency"`
CoverURL string `db:"cover_url"`
DetailHTML string `db:"detail_html"`
IsRecommended bool `db:"is_recommended"`
StartAt string `db:"start_at"`
EndAt string `db:"end_at"`
ClaimedCount int64 `db:"claimed_count"`
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
}
type ManualProductPage struct {
Items []ManualProduct
Total int64
}
func (s *Store) ManualProducts(ctx context.Context, tenantID, categoryID int64, offset, limit int) (*ManualProductPage, error) {
where := ` WHERE p.tenant_id=? AND p.product_type=2 AND p.publish_status=3
AND p.deleted_at IS NULL AND c.status=1 AND c.deleted_at IS NULL
AND (p.start_at IS NULL OR p.start_at<=UTC_TIMESTAMP(3))
AND (p.end_at IS NULL OR p.end_at>UTC_TIMESTAMP(3))`
args := []any{tenantID}
if categoryID > 0 {
where += ` AND p.category_id=?`
args = append(args, categoryID)
}
var total int64
if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_product p
JOIN hg_product_category c ON c.tenant_id=p.tenant_id AND c.id=p.category_id`+where, args...); err != nil {
return nil, err
}
args = append(args, limit, offset)
items := make([]ManualProduct, 0)
err := s.db.SelectContext(ctx, &items, manualProductSelectSQL()+where+
` ORDER BY p.is_recommended ASC,p.sort ASC,p.id ASC LIMIT ? OFFSET ?`, args...)
return &ManualProductPage{Items: items, Total: total}, err
}
func (s *Store) ManualProduct(ctx context.Context, tenantID, productID int64) (*ManualProduct, error) {
var row ManualProduct
err := s.db.GetContext(ctx, &row, manualProductSelectSQL()+` WHERE p.tenant_id=? AND p.id=?
AND p.product_type=2 AND p.publish_status=3 AND p.deleted_at IS NULL
AND c.status=1 AND c.deleted_at IS NULL
AND (p.start_at IS NULL OR p.start_at<=UTC_TIMESTAMP(3))
AND (p.end_at IS NULL OR p.end_at>UTC_TIMESTAMP(3)) LIMIT 1`, tenantID, productID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrResourceNotFound
}
return &row, err
}
func manualProductSelectSQL() string {
return `SELECT p.id,p.product_no,p.category_id,c.category_code,c.name AS category_name,
p.payment_type,p.name,p.shop_name,p.price_amount,p.currency,p.cover_url,
COALESCE(p.detail_html,'') AS detail_html,(p.is_recommended=1) AS is_recommended,
COALESCE(DATE_FORMAT(p.start_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS start_at,
COALESCE(DATE_FORMAT(p.end_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS end_at,
(SELECT COUNT(*) FROM hg_trade_order o WHERE o.tenant_id=p.tenant_id AND o.product_id=p.id
AND o.order_type=3 AND o.order_status NOT IN (7,8)) AS claimed_count,
DATE_FORMAT(p.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
DATE_FORMAT(p.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
FROM hg_product p JOIN hg_product_category c
ON c.tenant_id=p.tenant_id AND c.id=p.category_id`
}
+37
View File
@@ -0,0 +1,37 @@
package store
import (
"context"
"time"
"git.118im.com/im_team/im_workbench/internal/config"
"github.com/jmoiron/sqlx"
)
type Store struct {
db *sqlx.DB
}
func Open(cfg config.MySQLConfig) (*Store, error) {
db, err := sqlx.Open("mysql", cfg.DSN)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(cfg.MaxOpenConns)
db.SetMaxIdleConns(cfg.MaxIdleConns)
db.SetConnMaxLifetime(30 * time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, err
}
return &Store{db: db}, nil
}
func (s *Store) Close() error {
if s == nil || s.db == nil {
return nil
}
return s.db.Close()
}
+200
View File
@@ -0,0 +1,200 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
)
const publishStatusPublished = 3
type Banner struct {
ID int64 `db:"id"`
ImageURL string `db:"image_url"`
AltText string `db:"alt_text"`
TargetType int `db:"target_type"`
TargetValue string `db:"target_value"`
Sort int `db:"sort"`
}
type Content struct {
ContentKey string `db:"content_key"`
ContentType string `db:"content_type"`
Title string `db:"title"`
Summary string `db:"summary"`
BodyHTML string `db:"body_html"`
BodyText string `db:"body_text"`
CoverURL string `db:"cover_url"`
Version int `db:"version"`
PublishedAt string `db:"published_at"`
AssetIDs sql.NullString `db:"asset_ids"`
}
type MemberLevel struct {
ID int64 `db:"id"`
Code string `db:"level_code"`
Name string `db:"name"`
IconURL string `db:"icon_url"`
UpgradeAmount int64 `db:"upgrade_amount"`
GiftAmount int64 `db:"gift_amount"`
DailyOrderLimit int `db:"daily_order_limit"`
CommissionRate int `db:"commission_rate"`
DailyWithdrawLimit int `db:"daily_withdraw_limit"`
RuleJSON json.RawMessage `db:"rule_json"`
Version int `db:"version"`
Sort int `db:"sort"`
}
type MemberNotice struct {
ID int64 `db:"id"`
Title string `db:"title"`
Content string `db:"content"`
SourceType int `db:"source_type"`
StartAt string `db:"start_at"`
EndAt string `db:"end_at"`
}
type TaskSummary struct {
TodayCount int `db:"today_count" json:"today_count"`
TodayCommissionAmount int64 `db:"today_commission_amount" json:"today_commission_amount"`
YesterdayCommissionAmount int64 `db:"yesterday_commission_amount" json:"yesterday_commission_amount"`
}
type LeaderboardMember struct {
MemberID int64 `db:"member_id"`
Username string `db:"username"`
AvatarURL string `db:"avatar_url"`
CompletedCount int64 `db:"completed_count"`
}
type CustomerService struct {
ID int64 `db:"id"`
ServiceCode string `db:"service_code"`
Name string `db:"name"`
AvatarURL string `db:"avatar_url"`
WorkTime string `db:"work_time"`
OnlineStatus int `db:"online_status"`
ServiceURL string `db:"service_url"`
BackupContact string `db:"backup_contact"`
}
func (s *Store) CurrentSiteConfig(ctx context.Context, tenantID int64) (json.RawMessage, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
var raw []byte
err := s.db.GetContext(ctx, &raw, `SELECT config_json FROM hg_site_config_version
WHERE tenant_id=? AND publish_status=? AND (effective_at IS NULL OR effective_at<=UTC_TIMESTAMP(3))
ORDER BY COALESCE(effective_at,published_at,created_at) DESC,id DESC LIMIT 1`, tenantID, publishStatusPublished)
if errors.Is(err, sql.ErrNoRows) {
return json.RawMessage(`{}`), nil
}
return json.RawMessage(raw), err
}
func (s *Store) ActiveBanners(ctx context.Context, tenantID int64, position string) ([]Banner, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
rows := make([]Banner, 0)
err := s.db.SelectContext(ctx, &rows, `SELECT id,image_url,alt_text,target_type,COALESCE(target_value,'') AS target_value,sort
FROM hg_banner WHERE tenant_id=? AND position=? AND publish_status=?
AND (start_at IS NULL OR start_at<=UTC_TIMESTAMP(3)) AND (end_at IS NULL OR end_at>UTC_TIMESTAMP(3))
AND deleted_at IS NULL ORDER BY sort ASC,id ASC`, tenantID, position, publishStatusPublished)
return rows, err
}
func (s *Store) PublishedContent(ctx context.Context, tenantID int64, key string) (*Content, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
var row Content
err := s.db.GetContext(ctx, &row, `SELECT c.content_key,c.content_type,v.title,COALESCE(v.summary,'') AS summary,
v.body_html,COALESCE(v.body_text,'') AS body_text,COALESCE(v.cover_url,'') AS cover_url,
v.version_no AS version,COALESCE(DATE_FORMAT(c.published_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS published_at,v.asset_ids
FROM hg_content c JOIN hg_content_version v
ON v.tenant_id=c.tenant_id AND v.id=c.published_version_id
WHERE c.tenant_id=? AND c.content_key=? AND c.publish_status=? AND v.publish_status=?
AND (v.effective_at IS NULL OR v.effective_at<=UTC_TIMESTAMP(3))
AND c.deleted_at IS NULL LIMIT 1`, tenantID, key, publishStatusPublished, publishStatusPublished)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrResourceNotFound
}
return &row, err
}
func (s *Store) ActiveMemberLevels(ctx context.Context, tenantID int64) ([]MemberLevel, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
rows := make([]MemberLevel, 0)
err := s.db.SelectContext(ctx, &rows, `SELECT l.id,l.level_code,l.name,l.icon_url,l.sort,
v.upgrade_amount,v.gift_amount,v.daily_order_limit,v.commission_rate,v.daily_withdraw_limit,
COALESCE(v.rule_json,JSON_OBJECT()) AS rule_json,v.version_no AS version
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
WHERE l.tenant_id=? AND l.status=1 AND v.publish_status=?
AND (v.effective_at IS NULL OR v.effective_at<=UTC_TIMESTAMP(3))
AND l.deleted_at IS NULL ORDER BY l.sort ASC,l.id ASC`, tenantID, publishStatusPublished)
return rows, err
}
func (s *Store) ActiveMemberNotices(ctx context.Context, tenantID, memberID int64, limit int) ([]MemberNotice, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
rows := make([]MemberNotice, 0)
err := s.db.SelectContext(ctx, &rows, `SELECT id,title,content,source_type,
DATE_FORMAT(start_at,'%Y-%m-%dT%H:%i:%sZ') AS start_at,
DATE_FORMAT(end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_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 ?`, tenantID, memberID, limit)
return rows, err
}
func (s *Store) MemberTaskSummary(ctx context.Context, tenantID, memberID int64) (*TaskSummary, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
var row TaskSummary
err := s.db.GetContext(ctx, &row, `SELECT
(SELECT COUNT(*) FROM hg_trade_order o WHERE o.tenant_id=? AND o.member_id=?
AND o.created_at>=UTC_DATE() AND o.created_at<UTC_DATE()+INTERVAL 1 DAY) AS today_count,
(SELECT COALESCE(SUM(t.commission_amount),0) FROM hg_task_submission t WHERE t.tenant_id=? AND t.member_id=?
AND t.submission_status=4 AND t.settled_at>=UTC_DATE() AND t.settled_at<UTC_DATE()+INTERVAL 1 DAY) AS today_commission_amount,
(SELECT COALESCE(SUM(y.commission_amount),0) FROM hg_task_submission y WHERE y.tenant_id=? AND y.member_id=?
AND y.submission_status=4 AND y.settled_at>=UTC_DATE()-INTERVAL 1 DAY AND y.settled_at<UTC_DATE()) AS yesterday_commission_amount`,
tenantID, memberID, tenantID, memberID, tenantID, memberID)
return &row, err
}
func (s *Store) TaskLeaderboard(ctx context.Context, tenantID int64, limit int) ([]LeaderboardMember, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
rows := make([]LeaderboardMember, 0)
err := s.db.SelectContext(ctx, &rows, `SELECT m.id AS member_id,m.username,
COALESCE(a.file_url,'') AS avatar_url,COUNT(o.id) AS completed_count
FROM hg_app_member m
LEFT JOIN hg_app_member_profile p ON p.tenant_id=m.tenant_id AND p.member_id=m.id
LEFT JOIN hg_sys_attachment a ON a.id=p.avatar_attachment_id AND a.status=1
JOIN hg_trade_order o ON o.tenant_id=m.tenant_id AND o.member_id=m.id
AND o.order_type=2 AND o.order_status=4
WHERE m.tenant_id=? AND m.member_status=1 AND m.deleted_at IS NULL
GROUP BY m.id,m.username,a.file_url
ORDER BY completed_count DESC,m.id ASC LIMIT ?`, tenantID, limit)
return rows, err
}
func (s *Store) ActiveCustomerServices(ctx context.Context, tenantID int64) ([]CustomerService, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
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
WHERE tenant_id=? AND status=1 AND deleted_at IS NULL ORDER BY sort ASC,id ASC`, tenantID)
return rows, err
}
+161
View File
@@ -0,0 +1,161 @@
package store
import (
"context"
"database/sql"
"errors"
)
type MemberOverview struct {
TenantID int64 `db:"tenant_id"`
MemberID int64 `db:"member_id"`
MemberNo string `db:"member_no"`
Username string `db:"username"`
Mobile string `db:"mobile"`
InviteCode string `db:"invite_code"`
MemberStatus int `db:"member_status"`
MemberCategory int `db:"member_category"`
CreditScore int `db:"credit_score"`
AvatarAttachmentID int64 `db:"avatar_attachment_id"`
AvatarURL string `db:"avatar_url"`
LevelID int64 `db:"level_id"`
LevelCode string `db:"level_code"`
LevelName string `db:"level_name"`
LevelIconURL string `db:"level_icon_url"`
CommissionRate int `db:"commission_rate"`
AvailableAmount int64 `db:"available_amount"`
FrozenAmount int64 `db:"frozen_amount"`
TotalIncome int64 `db:"total_income"`
TotalExpense int64 `db:"total_expense"`
Currency string `db:"currency"`
WalletStatus int `db:"wallet_status"`
WalletVersion int `db:"wallet_version"`
}
type AccountSecurity struct {
PasswordHash string `db:"password_hash"`
TradePasswordHash string `db:"trade_password_hash"`
MemberStatus int `db:"member_status"`
}
type Avatar struct {
AttachmentID int64 `db:"attachment_id"`
URL string `db:"url"`
}
func (s *Store) MemberOverview(ctx context.Context, tenantID, memberID int64) (*MemberOverview, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
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,
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,
COALESCE(lv.commission_rate,0) AS commission_rate,
COALESCE(w.available_amount,0) AS available_amount,COALESCE(w.frozen_amount,0) AS frozen_amount,
COALESCE(w.total_income,0) AS total_income,COALESCE(w.total_expense,0) AS total_expense,
COALESCE(w.currency,'CNY') AS currency,COALESCE(w.account_status,1) AS wallet_status,
COALESCE(w.version,0) AS wallet_version
FROM hg_app_member m
LEFT JOIN hg_app_member_profile p ON p.tenant_id=m.tenant_id AND p.member_id=m.id
LEFT JOIN hg_sys_attachment a ON a.id=p.avatar_attachment_id AND a.status=1
LEFT JOIN hg_app_member_level l ON l.tenant_id=m.tenant_id AND l.id=m.level_id AND l.deleted_at IS NULL
LEFT JOIN hg_app_member_level_version lv ON lv.tenant_id=l.tenant_id AND lv.id=l.current_version_id
LEFT JOIN hg_wallet_account w ON w.tenant_id=m.tenant_id AND w.member_id=m.id AND w.currency='CNY'
WHERE m.tenant_id=? AND m.id=? AND m.member_status=1 AND m.deleted_at IS NULL LIMIT 1`, tenantID, memberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAccountNotFound
}
return &row, err
}
func (s *Store) UpdateAvatar(ctx context.Context, tenantID, memberID, attachmentID int64) (*Avatar, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
var activeMemberID int64
err = tx.GetContext(ctx, &activeMemberID, `SELECT id FROM hg_app_member
WHERE tenant_id=? AND id=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, tenantID, memberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAccountNotFound
}
if err != nil {
return nil, err
}
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
AND mime_type LIKE 'image/%' LIMIT 1 FOR UPDATE`, attachmentID, memberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAttachmentInvalid
}
if err != nil {
return nil, err
}
_, err = tx.ExecContext(ctx, `INSERT INTO hg_app_member_profile
(tenant_id,member_id,avatar_attachment_id,created_by,updated_by)
VALUES(?,?,?,0,0) ON DUPLICATE KEY UPDATE avatar_attachment_id=VALUES(avatar_attachment_id),updated_at=CURRENT_TIMESTAMP(3)`,
tenantID, memberID, attachmentID)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return &avatar, nil
}
func (s *Store) AccountSecurity(ctx context.Context, tenantID, memberID int64) (*AccountSecurity, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
var row AccountSecurity
err := s.db.GetContext(ctx, &row, `SELECT password_hash,trade_password_hash,member_status
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) UpdateLoginPassword(ctx context.Context, tenantID, memberID int64, oldHash, newHash string) error {
result, err := s.db.ExecContext(ctx, `UPDATE hg_app_member SET password_hash=?,updated_at=CURRENT_TIMESTAMP(3)
WHERE tenant_id=? AND id=? AND password_hash=? AND member_status=1 AND deleted_at IS NULL`,
newHash, tenantID, memberID, oldHash)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return ErrConcurrentUpdate
}
return nil
}
func (s *Store) UpdateTradePassword(ctx context.Context, tenantID, memberID int64, oldHash, newHash string) error {
result, err := s.db.ExecContext(ctx, `UPDATE hg_app_member SET trade_password_hash=?,updated_at=CURRENT_TIMESTAMP(3)
WHERE tenant_id=? AND id=? AND trade_password_hash=? AND member_status=1 AND deleted_at IS NULL`,
newHash, tenantID, memberID, oldHash)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return ErrConcurrentUpdate
}
return nil
}
+480
View File
@@ -0,0 +1,480 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"github.com/jmoiron/sqlx"
)
const (
orderStatusMatching = 1
orderStatusPending = 2
orderStatusProcessing = 3
orderStatusCompleted = 4
orderStatusExpired = 5
orderStatusFrozen = 6
orderStatusCancelled = 7
orderStatusFailed = 8
)
type TaskHall struct {
ID int64 `db:"id"`
Code string `db:"hall_code"`
Name string `db:"name"`
IconURL string `db:"icon_url"`
Summary string `db:"summary"`
CommissionRate int `db:"commission_rate"`
RateUnit string `db:"rate_unit"`
ClientRoute string `db:"client_route"`
}
type TaskEligibility struct {
MemberLevelID int64 `db:"member_level_id"`
MemberStatus int `db:"member_status"`
HallEligible bool `db:"hall_eligible"`
AvailableAmount int64 `db:"available_amount"`
WalletStatus int `db:"wallet_status"`
DailyLimit int `db:"daily_limit"`
TodayCount int `db:"today_count"`
PendingOrderID int64 `db:"pending_order_id"`
}
type ClaimTaskInput struct {
TenantID int64
MemberID int64
HallID int64
MinimumAmount int64
DeadlineAt time.Time
OrderNo string
}
type TaskOrder struct {
ID int64 `db:"id"`
OrderNo string `db:"order_no"`
TaskID int64 `db:"task_id"`
ProductID int64 `db:"product_id"`
ProductName string `db:"product_name"`
CoverURL string `db:"cover_url"`
Quantity int `db:"quantity"`
UnitPriceAmount int64 `db:"unit_price_amount"`
TotalAmount int64 `db:"total_amount"`
CommissionAmount int64 `db:"commission_amount"`
Currency string `db:"currency"`
MemberLevelID int64 `db:"member_level_id"`
RuleSnapshot json.RawMessage `db:"rule_snapshot"`
Message string `db:"message"`
OrderStatus int `db:"order_status"`
FreezeStatus int `db:"freeze_status"`
DeadlineAt string `db:"deadline_at"`
MatchedAt string `db:"matched_at"`
CompletedAt string `db:"completed_at"`
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
TaskTitle string `db:"task_title"`
TaskDescription string `db:"task_description"`
TaskDetailHTML string `db:"task_detail_html"`
SubmissionNo string `db:"submission_no"`
SubmissionStatus int `db:"submission_status"`
EvidenceText string `db:"evidence_text"`
EvidenceIDs json.RawMessage `db:"evidence_attachment_ids"`
ReviewReasonCode string `db:"review_reason_code"`
ReviewReason string `db:"review_reason"`
AllowResubmit int `db:"allow_resubmit"`
SubmittedAt string `db:"submitted_at"`
ReviewedAt string `db:"reviewed_at"`
SettledAt string `db:"settled_at"`
}
type TaskOrderPage struct {
Items []TaskOrder
Total int64
}
type TaskStats struct {
AvailableAmount int64 `db:"available_amount"`
FrozenAmount int64 `db:"frozen_amount"`
TodayCommissionAmount int64 `db:"today_commission_amount"`
YesterdayCommissionAmount int64 `db:"yesterday_commission_amount"`
TodayClaimCount int `db:"today_claim_count"`
TodayCompletedCount int `db:"today_completed_count"`
}
type TaskSubmissionInput struct {
TenantID int64
MemberID int64
OrderID int64
SubmissionNo string
EvidenceText string
AttachmentIDs []int64
}
type TaskSubmissionResult struct {
SubmissionNo string `db:"submission_no"`
OrderStatus int
SubmittedAt time.Time
}
func (s *Store) TaskHalls(ctx context.Context, tenantID, memberID int64) ([]TaskHall, error) {
rows := make([]TaskHall, 0)
err := s.db.SelectContext(ctx, &rows, `SELECT h.id,h.hall_code,h.name,h.icon_url,h.summary,
h.commission_rate,h.rate_unit,h.client_route FROM hg_task_hall h
JOIN hg_app_member m ON m.tenant_id=h.tenant_id AND m.id=? AND m.member_status=1 AND m.deleted_at IS NULL
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=? AND h.publish_status=3 AND h.deleted_at IS NULL ORDER BY h.sort,h.id`, memberID, tenantID)
return rows, err
}
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,
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,
COALESCE(w.available_amount,0) AS available_amount,COALESCE(w.account_status,0) AS wallet_status,
COALESCE(lv.daily_order_limit,0) AS daily_limit,
(SELECT COUNT(*) FROM hg_trade_order o WHERE o.tenant_id=m.tenant_id AND o.member_id=m.id
AND o.order_type=2 AND o.created_at>=UTC_DATE() AND o.created_at<UTC_DATE()+INTERVAL 1 DAY) AS today_count,
COALESCE((SELECT o2.id FROM hg_trade_order o2 WHERE o2.tenant_id=m.tenant_id AND o2.member_id=m.id
AND o2.order_type=2 AND o2.order_status IN (1,2,3,6) ORDER BY o2.id DESC LIMIT 1),0) AS pending_order_id
FROM hg_app_member m
LEFT JOIN hg_app_member_level l ON l.tenant_id=m.tenant_id AND l.id=m.level_id AND l.status=1 AND l.deleted_at IS NULL
LEFT JOIN hg_app_member_level_version lv ON lv.tenant_id=l.tenant_id AND lv.id=l.current_version_id AND lv.publish_status=3
LEFT JOIN hg_wallet_account w ON w.tenant_id=m.tenant_id AND w.member_id=m.id AND w.currency='CNY'
WHERE m.tenant_id=? AND m.id=? AND m.deleted_at IS NULL LIMIT 1`, hallID, tenantID, memberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAccountNotFound
}
return &row, err
}
func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder, error) {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
var member struct {
LevelID int64 `db:"level_id"`
}
err = tx.GetContext(ctx, &member, `SELECT level_id 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
}
if err != nil {
return nil, err
}
var hallCount int
err = tx.GetContext(ctx, &hallCount, `SELECT COUNT(*) 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=?
WHERE h.tenant_id=? AND h.id=? AND h.publish_status=3 AND h.deleted_at IS NULL`, member.LevelID, input.TenantID, input.HallID)
if err != nil {
return nil, err
}
if hallCount == 0 {
return nil, ErrLevelNotEligible
}
var wallet struct {
AvailableAmount int64 `db:"available_amount"`
Status int `db:"account_status"`
}
err = tx.GetContext(ctx, &wallet, `SELECT available_amount,account_status 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, ErrInsufficientBalance
}
if err != nil {
return nil, err
}
if wallet.Status != 1 {
return nil, ErrAccountRestricted
}
if wallet.AvailableAmount < input.MinimumAmount {
return nil, ErrInsufficientBalance
}
var pending int64
err = tx.GetContext(ctx, &pending, `SELECT id FROM hg_trade_order
WHERE tenant_id=? AND member_id=? AND order_type=2 AND order_status IN (1,2,3,6)
ORDER BY id DESC LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
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
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) {
return nil, ErrLevelNotEligible
}
if err != nil {
return nil, err
}
if 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 {
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
}
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,
})
if err != nil {
return nil, err
}
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, "任务匹配成功,请在截止时间前完成")
if err != nil {
if isDuplicate(err) {
return nil, ErrPendingOrderExists
}
return nil, err
}
orderID, err := result.LastInsertId()
if err != nil {
return nil, err
}
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
}
func (s *Store) TaskOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*TaskOrderPage, error) {
where := ` WHERE o.tenant_id=? AND o.member_id=? AND o.order_type=2`
args := []any{tenantID, memberID}
if status > 0 {
where += ` AND o.order_status=?`
args = append(args, status)
}
var total int64
if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_trade_order o`+where, args...); err != nil {
return nil, err
}
args = append(args, limit, offset)
rows := make([]TaskOrder, 0)
err := s.db.SelectContext(ctx, &rows, orderSelectSQL()+where+` ORDER BY o.created_at DESC,o.id DESC LIMIT ? OFFSET ?`, args...)
return &TaskOrderPage{Items: rows, Total: total}, err
}
func (s *Store) TaskOrder(ctx context.Context, tenantID, memberID, orderID int64) (*TaskOrder, error) {
var row TaskOrder
err := s.db.GetContext(ctx, &row, orderSelectSQL()+` WHERE o.tenant_id=? AND o.member_id=? AND o.order_type=2 AND o.id=? LIMIT 1`, tenantID, memberID, orderID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrOrderNotFound
}
return &row, err
}
func orderSelectSQL() string {
return `SELECT o.id,o.order_no,o.task_id,o.product_id,COALESCE(NULLIF(o.product_name,''),t.title,'') AS product_name,
COALESCE(NULLIF(p.cover_url,''),t.cover_url,'') AS cover_url,o.quantity,o.unit_price_amount,
o.total_amount,o.commission_amount,o.currency,o.member_level_id,
COALESCE(o.rule_snapshot,JSON_OBJECT()) AS rule_snapshot,o.message,o.order_status,o.freeze_status,
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.rule_snapshot,'$.deadline_at')),'') AS deadline_at,
COALESCE(DATE_FORMAT(o.matched_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS matched_at,
COALESCE(DATE_FORMAT(o.completed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS completed_at,
DATE_FORMAT(o.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at,
COALESCE(t.title,'') AS task_title,COALESCE(t.description,'') AS task_description,
COALESCE(t.detail_html,'') AS task_detail_html,COALESCE(s.submission_no,'') AS submission_no,
COALESCE(s.submission_status,0) AS submission_status,COALESCE(s.evidence_text,'') AS evidence_text,
COALESCE(s.evidence_attachment_ids,JSON_ARRAY()) AS evidence_attachment_ids,
COALESCE(s.review_reason_code,'') AS review_reason_code,COALESCE(s.review_reason,'') AS review_reason,
COALESCE(s.allow_resubmit,2) AS allow_resubmit,
COALESCE(DATE_FORMAT(s.created_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS submitted_at,
COALESCE(DATE_FORMAT(s.reviewed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS reviewed_at,
COALESCE(DATE_FORMAT(s.settled_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS settled_at
FROM hg_trade_order o LEFT JOIN hg_task t ON t.tenant_id=o.tenant_id AND t.id=o.task_id
LEFT JOIN hg_product p ON p.tenant_id=o.tenant_id AND p.id=o.product_id
LEFT JOIN hg_task_submission s ON s.tenant_id=o.tenant_id AND s.trade_order_id=o.id`
}
func (s *Store) TaskStats(ctx context.Context, tenantID, memberID int64) (*TaskStats, error) {
var row TaskStats
err := s.db.GetContext(ctx, &row, `SELECT
COALESCE(w.available_amount,0) AS available_amount,COALESCE(w.frozen_amount,0) AS frozen_amount,
(SELECT COALESCE(SUM(ts.commission_amount),0) FROM hg_task_submission ts WHERE ts.tenant_id=? AND ts.member_id=?
AND ts.submission_status=4 AND ts.settled_at>=UTC_DATE() AND ts.settled_at<UTC_DATE()+INTERVAL 1 DAY) AS today_commission_amount,
(SELECT COALESCE(SUM(ys.commission_amount),0) FROM hg_task_submission ys WHERE ys.tenant_id=? AND ys.member_id=?
AND ys.submission_status=4 AND ys.settled_at>=UTC_DATE()-INTERVAL 1 DAY AND ys.settled_at<UTC_DATE()) AS yesterday_commission_amount,
(SELECT COUNT(*) FROM hg_trade_order tc WHERE tc.tenant_id=? AND tc.member_id=?
AND tc.order_type=2 AND tc.created_at>=UTC_DATE() AND tc.created_at<UTC_DATE()+INTERVAL 1 DAY) AS today_claim_count,
(SELECT COUNT(*) FROM hg_trade_order cc WHERE cc.tenant_id=? AND cc.member_id=? AND cc.order_type=2 AND cc.order_status=4
AND cc.completed_at>=UTC_DATE() AND cc.completed_at<UTC_DATE()+INTERVAL 1 DAY) AS today_completed_count
FROM hg_app_member m LEFT JOIN hg_wallet_account w ON w.tenant_id=m.tenant_id AND w.member_id=m.id AND w.currency='CNY'
WHERE m.tenant_id=? AND m.id=? AND m.member_status=1 AND m.deleted_at IS NULL LIMIT 1`,
tenantID, memberID, tenantID, memberID, tenantID, memberID, tenantID, memberID, tenantID, memberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAccountNotFound
}
return &row, err
}
func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*TaskSubmissionResult, error) {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
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"`
}
err = tx.GetContext(ctx, &order, `SELECT task_id,member_level_id,commission_amount,order_status,
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)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrOrderNotFound
}
if err != nil {
return nil, err
}
if order.OrderStatus != orderStatusPending {
return nil, ErrOrderState
}
if order.DeadlineAt != "" {
if deadline, parseErr := time.Parse(time.RFC3339, order.DeadlineAt); parseErr != nil || !time.Now().UTC().Before(deadline) {
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
}
if commitErr := tx.Commit(); commitErr != nil {
return nil, commitErr
}
return nil, ErrOrderExpired
}
}
if err := validateEvidenceAttachments(ctx, tx, input.TenantID, input.MemberID, input.AttachmentIDs); err != nil {
return nil, err
}
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
(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)
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 err != nil {
return nil, err
}
if affected, _ := result.RowsAffected(); affected != 1 {
return nil, ErrOrderState
}
if err := tx.Commit(); err != nil {
return nil, err
}
return &TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: orderStatusProcessing, SubmittedAt: now}, nil
}
func validateEvidenceAttachments(ctx context.Context, tx *sqlx.Tx, tenantID, memberID int64, ids []int64) error {
if len(ids) == 0 {
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 (mime_type LIKE 'image/%' OR mime_type='application/pdf')`, ids, tenantID, memberID)
if err != nil {
return err
}
var count int
if err := tx.GetContext(ctx, &count, query, args...); err != nil {
return err
}
if count != len(ids) {
return ErrEvidenceInvalid
}
return nil
}
func (s *Store) OrderLedgers(ctx context.Context, tenantID, memberID int64, businessNumbers []string) ([]map[string]any, error) {
if len(businessNumbers) == 0 {
return []map[string]any{}, nil
}
query, args, err := sqlx.In(`SELECT id,ledger_no,biz_type,biz_no,entry_type,direction,amount,before_amount,after_amount,currency,remark,
DATE_FORMAT(occurred_at,'%Y-%m-%dT%H:%i:%sZ') AS occurred_at FROM hg_wallet_ledger
WHERE tenant_id=? AND member_id=? AND biz_no IN (?) ORDER BY occurred_at,id`, tenantID, memberID, businessNumbers)
if err != nil {
return nil, err
}
rows, err := s.db.QueryxContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]map[string]any, 0)
for rows.Next() {
item := map[string]any{}
if err := rows.MapScan(item); err != nil {
return nil, err
}
for key, value := range item {
if bytes, ok := value.([]byte); ok {
item[key] = string(bytes)
}
}
items = append(items, item)
}
return items, rows.Err()
}
+477
View File
@@ -0,0 +1,477 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"github.com/jmoiron/sqlx"
)
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"`
}
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
}
type WithdrawalChannel struct {
ID int64 `db:"id"`
Code string `db:"channel_code"`
Name string `db:"name"`
ChannelType int `db:"channel_type"`
MinAmount int64 `db:"min_amount"`
MaxAmount int64 `db:"max_amount"`
FeeType int `db:"fee_type"`
FeeFixedAmount int64 `db:"fee_fixed_amount"`
FeeRate int `db:"fee_rate"`
}
type WithdrawalWallet struct {
AccountID int64 `db:"account_id"`
AvailableAmount int64 `db:"available_amount"`
FrozenAmount int64 `db:"frozen_amount"`
AccountStatus int `db:"account_status"`
DailyLimit int `db:"daily_limit"`
TodayCount int `db:"today_count"`
}
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
}
type WithdrawalOrder struct {
ID int64 `db:"id"`
WithdrawalNo string `db:"withdrawal_no"`
PaymentAccountID int64 `db:"payment_account_id"`
ChannelID int64 `db:"channel_id"`
ChannelName string `db:"channel_name"`
RequestAmount int64 `db:"request_amount"`
FeeAmount int64 `db:"fee_amount"`
ActualAmount int64 `db:"actual_amount"`
Currency string `db:"currency"`
AccountMask string `db:"account_mask"`
BankName string `db:"bank_name"`
HolderMask string `db:"holder_mask"`
RiskResult json.RawMessage `db:"risk_result"`
Status int `db:"withdrawal_status"`
ReviewReasonCode string `db:"review_reason_code"`
ReviewReason string `db:"review_reason"`
ExternalNo string `db:"external_no"`
ReviewedAt string `db:"reviewed_at"`
ProcessedAt string `db:"processed_at"`
CompletedAt string `db:"completed_at"`
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
Replayed bool `db:"-"`
}
type WithdrawalPage struct {
Items []WithdrawalOrder
Total int64
}
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,
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
ORDER BY is_default ASC,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 {
return nil, err
}
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)
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 accountCount int
if err = tx.GetContext(ctx, &accountCount, `SELECT COUNT(*) FROM hg_app_member_payment_account
WHERE tenant_id=? AND member_id=? AND deleted_at IS NULL`, input.TenantID, input.MemberID); err != nil {
return nil, err
}
isDefault := 2
if accountCount == 0 {
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,
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,
isDefault, input.MemberID, input.MemberID)
if err != nil {
if isDuplicate(err) {
return nil, ErrPaymentAccountExists
}
return nil, err
}
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
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
}
func nullableString(value string) any {
if value == "" {
return nil
}
return value
}
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,
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`,
tenantID, memberID, accountID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrPaymentAccountInvalid
}
return &row, err
}
func (s *Store) ActiveWithdrawalChannel(ctx context.Context, tenantID int64, channelTypes []int) (*WithdrawalChannel, error) {
if len(channelTypes) == 0 {
return nil, ErrWithdrawalChannel
}
query, args, err := sqlx.In(`SELECT id,channel_code,name,channel_type,min_amount,max_amount,
fee_type,fee_fixed_amount,fee_rate FROM hg_payment_channel
WHERE tenant_id=? AND channel_type IN (?) AND direction IN (2,3) AND status=1 AND deleted_at IS NULL
ORDER BY CASE WHEN channel_type=? THEN 0 ELSE 1 END,sort,id LIMIT 1`, tenantID, channelTypes, channelTypes[0])
if err != nil {
return nil, err
}
var row WithdrawalChannel
err = s.db.GetContext(ctx, &row, query, args...)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrWithdrawalChannel
}
return &row, err
}
func (s *Store) WithdrawalWallet(ctx context.Context, tenantID, memberID int64) (*WithdrawalWallet, error) {
var row WithdrawalWallet
err := s.db.GetContext(ctx, &row, `SELECT w.id AS account_id,w.available_amount,w.frozen_amount,w.account_status,
COALESCE(lv.daily_withdraw_limit,0) AS daily_limit,
(SELECT COUNT(*) FROM hg_withdrawal_order o WHERE o.tenant_id=w.tenant_id AND o.member_id=w.member_id
AND o.created_at>=UTC_DATE() AND o.created_at<UTC_DATE()+INTERVAL 1 DAY) AS today_count
FROM hg_wallet_account w JOIN hg_app_member m ON m.tenant_id=w.tenant_id AND m.id=w.member_id
LEFT JOIN hg_app_member_level l ON l.tenant_id=m.tenant_id AND l.id=m.level_id AND l.status=1 AND l.deleted_at IS NULL
LEFT JOIN hg_app_member_level_version lv ON lv.tenant_id=l.tenant_id AND lv.id=l.current_version_id AND lv.publish_status=3
WHERE w.tenant_id=? AND w.member_id=? AND w.currency='CNY' AND m.member_status=1 AND m.deleted_at IS NULL LIMIT 1`,
tenantID, memberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAccountRestricted
}
return &row, err
}
func (s *Store) CreateWithdrawal(ctx context.Context, input WithdrawalCreation) (*WithdrawalOrder, error) {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
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)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrConcurrentUpdate
}
if err != nil {
return nil, err
}
if existing, findErr := withdrawalByNo(ctx, tx, input.TenantID, input.MemberID, input.WithdrawalNo); findErr == nil {
if existing.PaymentAccountID != input.PaymentAccountID || existing.RequestAmount != input.RequestAmount || existing.Currency != input.Currency {
return nil, ErrIdempotencyConflict
}
existing.Replayed = true
if err := tx.Commit(); err != nil {
return nil, err
}
return existing, nil
} else if !errors.Is(findErr, ErrWithdrawalNotFound) {
return nil, findErr
}
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
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) {
return nil, ErrPaymentAccountInvalid
}
if err != nil {
return nil, err
}
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 {
return nil, ErrConcurrentUpdate
}
var channel WithdrawalChannel
err = tx.GetContext(ctx, &channel, `SELECT id,channel_code,name,channel_type,min_amount,max_amount,
fee_type,fee_fixed_amount,fee_rate FROM hg_payment_channel WHERE tenant_id=? AND id=?
AND direction IN (2,3) AND status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.Channel.ID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrWithdrawalChannel
}
if err != nil {
return nil, err
}
if channel != input.Channel {
return nil, ErrConcurrentUpdate
}
if input.RequestAmount < maxInt64(1, channel.MinAmount) || (channel.MaxAmount > 0 && input.RequestAmount > channel.MaxAmount) {
return nil, ErrWithdrawalAmount
}
var wallet WithdrawalWallet
err = tx.GetContext(ctx, &wallet, `SELECT w.id AS account_id,w.available_amount,w.frozen_amount,w.account_status,
COALESCE(lv.daily_withdraw_limit,0) AS daily_limit,
(SELECT COUNT(*) FROM hg_withdrawal_order o WHERE o.tenant_id=w.tenant_id AND o.member_id=w.member_id
AND o.created_at>=UTC_DATE() AND o.created_at<UTC_DATE()+INTERVAL 1 DAY) AS today_count
FROM hg_wallet_account w JOIN hg_app_member m ON m.tenant_id=w.tenant_id AND m.id=w.member_id
LEFT JOIN hg_app_member_level l ON l.tenant_id=m.tenant_id AND l.id=m.level_id AND l.status=1 AND l.deleted_at IS NULL
LEFT JOIN hg_app_member_level_version lv ON lv.tenant_id=l.tenant_id AND lv.id=l.current_version_id AND lv.publish_status=3
WHERE w.tenant_id=? AND w.member_id=? AND w.currency=? LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID, input.Currency)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAccountRestricted
}
if err != nil {
return nil, err
}
if wallet.AccountStatus != 1 {
return nil, ErrAccountRestricted
}
if wallet.DailyLimit > 0 && wallet.TodayCount >= wallet.DailyLimit {
return nil, ErrWithdrawalLimit
}
if wallet.AvailableAmount < input.RequestAmount {
return nil, ErrInsufficientBalance
}
riskResult := json.RawMessage(`{"result":"passed","mode":"sandbox"}`)
result, err := tx.ExecContext(ctx, `INSERT INTO hg_withdrawal_order
(tenant_id,withdrawal_no,member_id,payment_account_id,channel_id,request_amount,fee_amount,
actual_amount,currency,account_snapshot_cipher,account_mask,bank_name,holder_mask,risk_result,
withdrawal_status,created_by,updated_by)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`, input.TenantID, input.WithdrawalNo, input.MemberID,
input.PaymentAccountID, channel.ID, input.RequestAmount, input.FeeAmount, input.ActualAmount,
input.Currency, input.AccountSnapshotCipher, input.AccountMask, input.BankName, input.HolderMask,
riskResult, input.MemberID, input.MemberID)
if err != nil {
if isDuplicate(err) {
return nil, ErrIdempotencyConflict
}
return nil, err
}
orderID, err := result.LastInsertId()
if err != nil {
return nil, err
}
beforeAvailable := wallet.AvailableAmount
afterAvailable := beforeAvailable - input.RequestAmount
beforeFrozen := wallet.FrozenAmount
afterFrozen := beforeFrozen + input.RequestAmount
update, err := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,version=version+1,
updated_at=UTC_TIMESTAMP(3) WHERE id=? AND tenant_id=? AND available_amount=? AND frozen_amount=? AND account_status=1`,
afterAvailable, afterFrozen, wallet.AccountID, input.TenantID, beforeAvailable, beforeFrozen)
if err != nil {
return nil, err
}
if affected, _ := update.RowsAffected(); affected != 1 {
return nil, ErrConcurrentUpdate
}
availableLedgerID, err := insertWithdrawalLedger(ctx, tx, input, wallet.AccountID, input.AvailableLedgerNo,
"available", 2, beforeAvailable, afterAvailable, "available", "取出申请冻结可用余额")
if err != nil {
return nil, err
}
_ = availableLedgerID
frozenLedgerID, err := insertWithdrawalLedger(ctx, tx, input, wallet.AccountID, input.FrozenLedgerNo,
"frozen", 1, beforeFrozen, afterFrozen, "frozen", "取出申请增加冻结余额")
if err != nil {
return nil, err
}
if _, err = tx.ExecContext(ctx, `UPDATE hg_withdrawal_order SET freeze_ledger_id=? WHERE tenant_id=? AND id=?`,
frozenLedgerID, input.TenantID, orderID); err != nil {
return nil, err
}
if err = tx.Commit(); err != nil {
return nil, err
}
return &WithdrawalOrder{ID: orderID, WithdrawalNo: input.WithdrawalNo,
PaymentAccountID: input.PaymentAccountID, ChannelID: channel.ID, ChannelName: channel.Name,
RequestAmount: input.RequestAmount, FeeAmount: input.FeeAmount, ActualAmount: input.ActualAmount,
Currency: input.Currency, AccountMask: input.AccountMask, BankName: input.BankName,
HolderMask: input.HolderMask, RiskResult: riskResult, Status: 1}, nil
}
func insertWithdrawalLedger(ctx context.Context, tx *sqlx.Tx, input WithdrawalCreation, accountID int64,
ledgerNo, entryType string, direction int, beforeAmount, afterAmount int64, suffix, 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(?,?,?,?,?,?,?,?,?,?,?,?,0,?,?,?,UTC_TIMESTAMP(3))`, input.TenantID, ledgerNo, accountID,
input.MemberID, "withdraw_freeze", input.WithdrawalNo, entryType, direction, input.RequestAmount,
beforeAmount, afterAmount, input.Currency, "withdrawal:"+input.WithdrawalNo+":"+suffix, remark, input.MemberID)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
func (s *Store) WithdrawalOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*WithdrawalPage, error) {
where := ` WHERE o.tenant_id=? AND o.member_id=?`
args := []any{tenantID, memberID}
if status > 0 {
where += ` AND o.withdrawal_status=?`
args = append(args, status)
}
var total int64
if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_withdrawal_order o`+where, args...); err != nil {
return nil, err
}
args = append(args, limit, offset)
items := make([]WithdrawalOrder, 0)
err := s.db.SelectContext(ctx, &items, withdrawalSelectSQL()+where+
` ORDER BY o.created_at DESC,o.id DESC LIMIT ? OFFSET ?`, args...)
return &WithdrawalPage{Items: items, Total: total}, err
}
func (s *Store) WithdrawalOrder(ctx context.Context, tenantID, memberID, orderID int64) (*WithdrawalOrder, error) {
var row WithdrawalOrder
err := s.db.GetContext(ctx, &row, withdrawalSelectSQL()+
` WHERE o.tenant_id=? AND o.member_id=? AND o.id=? LIMIT 1`, tenantID, memberID, orderID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrWithdrawalNotFound
}
return &row, err
}
func withdrawalByNo(ctx context.Context, db queryer, tenantID, memberID int64, no string) (*WithdrawalOrder, error) {
query := withdrawalSelectSQL() + ` WHERE o.tenant_id=? AND o.member_id=? AND o.withdrawal_no=? LIMIT 1`
var row WithdrawalOrder
err := db.GetContext(ctx, &row, query, tenantID, memberID, no)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrWithdrawalNotFound
}
return &row, err
}
func withdrawalSelectSQL() string {
return `SELECT o.id,o.withdrawal_no,o.payment_account_id,o.channel_id,COALESCE(c.name,'') AS channel_name,
o.request_amount,o.fee_amount,o.actual_amount,o.currency,o.account_mask,o.bank_name,o.holder_mask,
COALESCE(o.risk_result,JSON_OBJECT()) AS risk_result,o.withdrawal_status,o.review_reason_code,
o.review_reason,o.external_no,
COALESCE(DATE_FORMAT(o.reviewed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS reviewed_at,
COALESCE(DATE_FORMAT(o.processed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS processed_at,
COALESCE(DATE_FORMAT(o.completed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS completed_at,
DATE_FORMAT(o.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
FROM hg_withdrawal_order o LEFT JOIN hg_payment_channel c ON c.tenant_id=o.tenant_id AND c.id=o.channel_id`
}
func maxInt64(left, right int64) int64 {
if left > right {
return left
}
return right
}
+14
View File
@@ -0,0 +1,14 @@
package tasks
import "errors"
var (
ErrHallIDInvalid = errors.New("hall id invalid")
ErrOrderIDInvalid = errors.New("order id invalid")
ErrPageInvalid = errors.New("page invalid")
ErrPageSizeInvalid = errors.New("page size invalid")
ErrStatusInvalid = errors.New("status invalid")
ErrEvidenceInvalid = errors.New("evidence invalid")
ErrTaskDisabled = errors.New("task feature disabled")
ErrOutsideBusinessHour = errors.New("outside business hours")
)
+553
View File
@@ -0,0 +1,553 @@
package tasks
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"git.118im.com/im_team/im_workbench/internal/store"
)
const (
defaultPageSize = 20
maxPageSize = 100
defaultDeadlineMinute = 15
)
var shanghai = time.FixedZone("Asia/Shanghai", 8*60*60)
type Repository interface {
CurrentSiteConfig(context.Context, int64) (json.RawMessage, error)
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)
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)
SubmitTask(context.Context, store.TaskSubmissionInput) (*store.TaskSubmissionResult, error)
OrderLedgers(context.Context, int64, int64, []string) ([]map[string]any, error)
}
type Service struct {
repository Repository
now func() time.Time
}
type Hall struct {
ID int64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
IconURL string `json:"icon_url"`
Summary string `json:"summary"`
CommissionRate int `json:"commission_rate"`
RateUnit string `json:"rate_unit"`
ClientRoute string `json:"client_route"`
}
type Eligibility struct {
Eligible bool `json:"eligible"`
Reason string `json:"reason"`
Message string `json:"message"`
AvailableAmount int64 `json:"available_amount"`
MinimumAmount int64 `json:"minimum_amount"`
TodayCount int `json:"today_count"`
DailyLimit int `json:"daily_limit"`
PendingOrderID int64 `json:"pending_order_id"`
BusinessStartAt string `json:"business_start_at"`
BusinessEndAt string `json:"business_end_at"`
}
type Order struct {
ID int64 `json:"id"`
OrderNo string `json:"order_no"`
TaskID int64 `json:"task_id"`
Product Product `json:"product"`
Quantity int `json:"quantity"`
UnitPriceAmount int64 `json:"unit_price_amount"`
TotalAmount int64 `json:"total_amount"`
CommissionAmount int64 `json:"commission_amount"`
Currency string `json:"currency"`
Status string `json:"status"`
StatusText string `json:"status_text"`
Message string `json:"message"`
DeadlineAt string `json:"deadline_at"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Task *TaskDetail `json:"task,omitempty"`
RuleSnapshot json.RawMessage `json:"rule_snapshot,omitempty"`
Submission *Submission `json:"submission,omitempty"`
Timeline []TimelineEntry `json:"timeline,omitempty"`
Ledgers []map[string]any `json:"ledgers,omitempty"`
AllowedActions []string `json:"allowed_actions,omitempty"`
}
type Product struct {
ID int64 `json:"id"`
Name string `json:"name"`
CoverURL string `json:"cover_url"`
}
type TaskDetail struct {
Title string `json:"title"`
Description string `json:"description"`
DetailHTML string `json:"detail_html"`
}
type Submission struct {
SubmissionNo string `json:"submission_no"`
Status string `json:"status"`
StatusText string `json:"status_text"`
EvidenceText string `json:"evidence_text"`
EvidenceAttachmentIDs []int64 `json:"evidence_attachment_ids"`
ReviewReasonCode string `json:"review_reason_code"`
ReviewReason string `json:"review_reason"`
AllowResubmit bool `json:"allow_resubmit"`
SubmittedAt string `json:"submitted_at"`
ReviewedAt string `json:"reviewed_at"`
SettledAt string `json:"settled_at"`
}
type TimelineEntry struct {
Status string `json:"status"`
Text string `json:"text"`
At string `json:"at"`
}
type OrderPage struct {
Items []Order `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
}
type Stats struct {
AvailableAmount int64 `json:"available_amount"`
FrozenAmount int64 `json:"frozen_amount"`
TotalAssetAmount int64 `json:"total_asset_amount"`
TodayCommissionAmount int64 `json:"today_commission_amount"`
YesterdayCommissionAmount int64 `json:"yesterday_commission_amount"`
TodayClaimCount int `json:"today_claim_count"`
TodayCompletedCount int `json:"today_completed_count"`
}
type SubmitInput struct {
EvidenceText string `json:"evidence_text"`
EvidenceAttachmentIDs []int64 `json:"evidence_attachment_ids"`
}
type SubmitResult struct {
SubmissionNo string `json:"submission_no"`
Status string `json:"status"`
SubmittedAt string `json:"submitted_at"`
OrderStatus string `json:"order_status"`
}
type taskPolicy struct {
Enabled bool
MinimumAmount int64
Start string
End string
Deadline time.Duration
}
func New(repository Repository) *Service {
return &Service{repository: repository, now: time.Now}
}
func (s *Service) Halls(ctx context.Context, tenantID, memberID int64) ([]Hall, error) {
rows, err := s.repository.TaskHalls(ctx, tenantID, memberID)
if err != nil {
return nil, err
}
items := make([]Hall, 0, len(rows))
for _, row := range rows {
items = append(items, Hall{ID: row.ID, Code: row.Code, Name: row.Name, IconURL: row.IconURL,
Summary: row.Summary, CommissionRate: row.CommissionRate, RateUnit: row.RateUnit, ClientRoute: row.ClientRoute})
}
return items, nil
}
func (s *Service) Eligibility(ctx context.Context, tenantID, memberID, hallID int64) (*Eligibility, error) {
if hallID <= 0 {
return nil, ErrHallIDInvalid
}
policy, err := s.policy(ctx, tenantID)
if err != nil {
return nil, err
}
row, err := s.repository.TaskEligibility(ctx, tenantID, memberID, hallID)
if err != nil {
return nil, err
}
result := &Eligibility{Eligible: true, AvailableAmount: row.AvailableAmount, MinimumAmount: policy.MinimumAmount,
TodayCount: row.TodayCount, DailyLimit: row.DailyLimit, PendingOrderID: row.PendingOrderID,
BusinessStartAt: policy.Start + "+08:00", BusinessEndAt: policy.End + "+08:00"}
switch {
case !policy.Enabled:
result.reject("TASK_DISABLED", "任务功能未开启")
case !withinBusinessHours(s.now().In(shanghai), policy.Start, policy.End):
result.reject("OUTSIDE_BUSINESS_HOURS", "当前不在营业时间")
case row.MemberStatus != 1:
result.reject("ACCOUNT_RESTRICTED", "账户状态受限")
case !row.HallEligible:
result.reject("LEVEL_NOT_ELIGIBLE", "当前等级不能进入该任务大厅")
case row.WalletStatus != 1:
result.reject("ACCOUNT_RESTRICTED", "账户状态受限")
case row.AvailableAmount < policy.MinimumAmount:
result.reject("INSUFFICIENT_BALANCE", "可用余额不足")
case row.DailyLimit > 0 && row.TodayCount >= row.DailyLimit:
result.reject("DAILY_LIMIT_REACHED", "已达当日领取上限")
case row.PendingOrderID > 0:
result.reject("PENDING_ORDER_EXISTS", "存在未完成订单")
}
return result, nil
}
func (e *Eligibility) reject(reason, message string) {
e.Eligible = false
e.Reason = reason
e.Message = message
}
func (s *Service) Claim(ctx context.Context, tenantID, memberID, hallID int64) (*Order, error) {
if hallID <= 0 {
return nil, ErrHallIDInvalid
}
policy, err := s.policy(ctx, tenantID)
if err != nil {
return nil, err
}
if !policy.Enabled {
return nil, ErrTaskDisabled
}
if !withinBusinessHours(s.now().In(shanghai), policy.Start, policy.End) {
return nil, ErrOutsideBusinessHour
}
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)})
if err != nil {
return nil, err
}
result := orderFromStore(*row, false)
return &result, nil
}
func (s *Service) Orders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*OrderPage, error) {
if page == 0 {
page = 1
}
if pageSize == 0 {
pageSize = defaultPageSize
}
if page < 1 {
return nil, ErrPageInvalid
}
if pageSize < 1 || pageSize > maxPageSize {
return nil, ErrPageSizeInvalid
}
statusCode, ok := orderStatusCode(status)
if !ok {
return nil, ErrStatusInvalid
}
rows, err := s.repository.TaskOrders(ctx, tenantID, memberID, statusCode, (page-1)*pageSize, pageSize)
if err != nil {
return nil, err
}
items := make([]Order, 0, len(rows.Items))
for _, row := range rows.Items {
items = append(items, orderFromStore(row, false))
}
return &OrderPage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil
}
func (s *Service) Order(ctx context.Context, tenantID, memberID, orderID int64) (*Order, error) {
if orderID <= 0 {
return nil, ErrOrderIDInvalid
}
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
}
result := orderFromStore(*row, true)
result.Ledgers = ledgers
return &result, nil
}
func (s *Service) Stats(ctx context.Context, tenantID, memberID int64) (*Stats, error) {
row, err := s.repository.TaskStats(ctx, tenantID, memberID)
if err != nil {
return nil, err
}
return &Stats{AvailableAmount: row.AvailableAmount, FrozenAmount: row.FrozenAmount,
TotalAssetAmount: row.AvailableAmount + row.FrozenAmount, TodayCommissionAmount: row.TodayCommissionAmount,
YesterdayCommissionAmount: row.YesterdayCommissionAmount, TodayClaimCount: row.TodayClaimCount,
TodayCompletedCount: row.TodayCompletedCount}, nil
}
func (s *Service) Submit(ctx context.Context, tenantID, memberID, orderID int64, input SubmitInput) (*SubmitResult, error) {
if orderID <= 0 {
return nil, ErrOrderIDInvalid
}
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) {
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})
if err != nil {
return nil, err
}
return &SubmitResult{SubmissionNo: row.SubmissionNo, Status: "reviewing",
SubmittedAt: row.SubmittedAt.Format(time.RFC3339), OrderStatus: orderStatusName(row.OrderStatus)}, nil
}
func (s *Service) policy(ctx context.Context, tenantID int64) (taskPolicy, error) {
raw, err := s.repository.CurrentSiteConfig(ctx, tenantID)
if err != nil {
return taskPolicy{}, err
}
var config map[string]any
if json.Unmarshal(raw, &config) != nil {
config = map[string]any{}
}
minutes := intValue(findValue(config, "task_deadline_minutes", "order_deadline_minutes", "taskOrderDeadlineMinutes"))
if minutes <= 0 || minutes > 24*60 {
minutes = defaultDeadlineMinute
}
start := clockValue(findValue(config, "task_business_start_at", "business_start_at", "taskBusinessStartAt"), "00:00:00")
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"))),
Start: start, End: end, Deadline: time.Duration(minutes) * time.Minute}, nil
}
func orderFromStore(row store.TaskOrder, detail bool) Order {
status := orderStatusName(row.OrderStatus)
result := Order{ID: row.ID, OrderNo: row.OrderNo, TaskID: row.TaskID,
Product: Product{ID: row.ProductID, Name: row.ProductName, CoverURL: row.CoverURL}, Quantity: row.Quantity,
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}
if !detail {
return result
}
result.Task = &TaskDetail{Title: row.TaskTitle, Description: row.TaskDescription, DetailHTML: row.TaskDetailHTML}
result.RuleSnapshot = row.RuleSnapshot
result.Timeline = orderTimeline(row)
result.AllowedActions = allowedActions(row)
if row.SubmissionNo != "" {
ids := make([]int64, 0)
_ = json.Unmarshal(row.EvidenceIDs, &ids)
result.Submission = &Submission{SubmissionNo: row.SubmissionNo, Status: submissionStatusName(row.SubmissionStatus),
StatusText: submissionStatusText(row.SubmissionStatus), EvidenceText: row.EvidenceText,
EvidenceAttachmentIDs: ids, ReviewReasonCode: row.ReviewReasonCode, ReviewReason: row.ReviewReason,
AllowResubmit: row.AllowResubmit == 1, SubmittedAt: row.SubmittedAt, ReviewedAt: row.ReviewedAt, SettledAt: row.SettledAt}
}
return result
}
func orderTimeline(row store.TaskOrder) []TimelineEntry {
items := []TimelineEntry{{Status: "created", Text: "订单已创建", At: row.CreatedAt}}
if row.MatchedAt != "" {
items = append(items, TimelineEntry{Status: "matched", Text: "任务匹配成功", At: row.MatchedAt})
}
if row.SubmittedAt != "" {
items = append(items, TimelineEntry{Status: "submitted", Text: "任务凭证已提交", At: row.SubmittedAt})
}
if row.ReviewedAt != "" {
items = append(items, TimelineEntry{Status: submissionStatusName(row.SubmissionStatus), Text: submissionStatusText(row.SubmissionStatus), At: row.ReviewedAt})
}
if row.SettledAt != "" {
items = append(items, TimelineEntry{Status: "settled", Text: "佣金已结算", At: row.SettledAt})
}
if row.CompletedAt != "" {
items = append(items, TimelineEntry{Status: "completed", Text: "订单已完成", At: row.CompletedAt})
}
return items
}
func allowedActions(row store.TaskOrder) []string {
switch row.OrderStatus {
case 2:
return []string{"submit", "contact_support"}
case 3, 5, 6, 8:
return []string{"contact_support"}
default:
return []string{}
}
}
func orderStatusCode(value string) (int, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "all":
return 0, true
case "matching":
return 1, true
case "pending":
return 2, true
case "processing":
return 3, true
case "completed":
return 4, true
case "expired":
return 5, true
case "frozen":
return 6, true
case "cancelled":
return 7, true
case "failed":
return 8, true
default:
return 0, false
}
}
func orderStatusName(status int) string {
names := map[int]string{1: "matching", 2: "pending", 3: "processing", 4: "completed", 5: "expired", 6: "frozen", 7: "cancelled", 8: "failed"}
if value, ok := names[status]; ok {
return value
}
return "unknown"
}
func orderStatusText(status int) string {
names := map[int]string{1: "匹配中", 2: "待处理", 3: "审核中", 4: "已完成", 5: "已超时", 6: "已冻结", 7: "已取消", 8: "失败"}
if value, ok := names[status]; ok {
return value
}
return "未知状态"
}
func submissionStatusName(status int) string {
names := map[int]string{1: "reviewing", 2: "approved", 3: "rejected", 4: "settled", 5: "reversed"}
return names[status]
}
func submissionStatusText(status int) string {
names := map[int]string{1: "待审核", 2: "已通过", 3: "已驳回", 4: "已结算", 5: "已冲正"}
return names[status]
}
func withinBusinessHours(now time.Time, start, end string) bool {
current := now.Hour()*3600 + now.Minute()*60 + now.Second()
startSecond := parseClock(start)
endSecond := parseClock(end)
if startSecond <= endSecond {
return current >= startSecond && current <= endSecond
}
return current >= startSecond || current <= endSecond
}
func parseClock(value string) int {
parsed, err := time.Parse("15:04:05", value)
if err != nil {
return 0
}
return parsed.Hour()*3600 + parsed.Minute()*60 + parsed.Second()
}
func clockValue(value any, fallback string) string {
raw, ok := value.(string)
if !ok {
return fallback
}
raw = strings.TrimSpace(raw)
for _, layout := range []string{"15:04:05", "15:04"} {
if parsed, err := time.Parse(layout, raw); err == nil {
return parsed.Format("15:04:05")
}
}
return fallback
}
func findValue(config map[string]any, keys ...string) any {
for _, key := range keys {
for candidate, value := range config {
if strings.EqualFold(candidate, key) {
return value
}
}
}
for _, value := range config {
if nested, ok := value.(map[string]any); ok {
if found := findValue(nested, keys...); found != nil {
return found
}
}
}
return nil
}
func boolValue(value any) bool {
switch typed := value.(type) {
case bool:
return typed
case float64:
return typed == 1
case string:
return typed == "1" || strings.EqualFold(typed, "true") || strings.EqualFold(typed, "on")
default:
return false
}
}
func intValue(value any) int {
return int(int64Value(value))
}
func int64Value(value any) int64 {
switch typed := value.(type) {
case float64:
return int64(typed)
case json.Number:
parsed, _ := typed.Int64()
return parsed
case string:
parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
return parsed
default:
return 0
}
}
func max64(left, right int64) int64 {
if left > right {
return left
}
return right
}
func validUniqueIDs(ids []int64) bool {
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id <= 0 {
return false
}
if _, exists := seen[id]; exists {
return false
}
seen[id] = struct{}{}
}
return true
}
func newNumber(prefix string, now time.Time) string {
bytes := make([]byte, 4)
if _, err := rand.Read(bytes); err != nil {
return fmt.Sprintf("%s%s%06d", prefix, now.Format("20060102150405"), now.UnixNano()%1_000_000)
}
return prefix + now.Format("20060102150405") + strings.ToUpper(hex.EncodeToString(bytes))
}
+117
View File
@@ -0,0 +1,117 @@
package tasks
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"git.118im.com/im_team/im_workbench/internal/store"
)
type fakeRepository struct {
config json.RawMessage
eligibility *store.TaskEligibility
order *store.TaskOrder
claimed store.ClaimTaskInput
submitted store.TaskSubmissionInput
}
func (f *fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) {
return f.config, nil
}
func (f *fakeRepository) TaskHalls(context.Context, int64, int64) ([]store.TaskHall, error) {
return []store.TaskHall{{ID: 1, Code: "DEFAULT", Name: "任务大厅"}}, nil
}
func (f *fakeRepository) TaskEligibility(context.Context, int64, int64, int64) (*store.TaskEligibility, error) {
return f.eligibility, nil
}
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",
DeadlineAt: input.DeadlineAt.Format(time.RFC3339)}, 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
}
func (f *fakeRepository) TaskOrder(context.Context, int64, int64, int64) (*store.TaskOrder, error) {
return f.order, nil
}
func (f *fakeRepository) TaskStats(context.Context, int64, int64) (*store.TaskStats, error) {
return &store.TaskStats{AvailableAmount: 100, FrozenAmount: 25}, nil
}
func (f *fakeRepository) SubmitTask(_ context.Context, input store.TaskSubmissionInput) (*store.TaskSubmissionResult, error) {
f.submitted = input
return &store.TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: 3,
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) {
return []map[string]any{}, nil
}
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}}
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" || result.MinimumAmount != 50000 {
t.Fatalf("unexpected eligibility: %#v", result)
}
}
func TestClaimFailsClosedWithoutTaskSwitch(t *testing.T) {
repository := &fakeRepository{config: json.RawMessage(`{}`)}
service := New(repository)
_, err := service.Claim(context.Background(), 0, 10, 1)
if !errors.Is(err, ErrTaskDisabled) {
t.Fatalf("expected task disabled, got %v", err)
}
}
func TestClaimBuildsPendingOrder(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)
service.now = func() time.Time { return now }
result, err := service.Claim(context.Background(), 0, 10, 2)
if err != nil {
t.Fatal(err)
}
if result.Status != "pending" || 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) {
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}})
if err != nil {
t.Fatal(err)
}
if result.Status != "reviewing" || result.OrderStatus != "processing" || repository.submitted.EvidenceText != "已完成" {
t.Fatalf("unexpected submission: result=%#v input=%#v", result, repository.submitted)
}
}
func TestOrdersRejectsUnknownStatus(t *testing.T) {
service := New(&fakeRepository{})
_, err := service.Orders(context.Background(), 0, 10, 1, 20, "not-a-status")
if !errors.Is(err, ErrStatusInvalid) {
t.Fatalf("expected status error, got %v", err)
}
}
+19
View File
@@ -0,0 +1,19 @@
root = true
[*]
charset=utf-8
end_of_line=LF
insert_final_newline=true
indent_style=space
indent_size=2
max_line_length = 100
[*.{yml,yaml,json}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
+3
View File
@@ -0,0 +1,3 @@
VITE_PUBLIC_PATH=/
VITE_PORT=5173
VITE_API_TARGET=http://localhost:6061
+1
View File
@@ -0,0 +1 @@
VITE_PUBLIC_PATH=/
+30
View File
@@ -0,0 +1,30 @@
.DS_Store
node_modules
/screenshots
/dist
dist.zip
dist_electron
package-lock.json
yarn.lock
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
/components.d.ts
/build/
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<meta name="theme-color" content="#ffffff" />
<title>Olons</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
{
"name": "olons-web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@vicons/ionicons5": "^0.12.0",
"axios": "^1.7.7",
"naive-ui": "^2.44.1",
"vue": "^3.4.38",
"vue-router": "^4.4.3"
},
"devDependencies": {
"@types/node": "^22.5.2",
"@vitejs/plugin-vue": "^5.1.3",
"typescript": "^5.5.4",
"vite": "^5.4.2"
},
"engines": {
"node": ">=18"
}
}
+1210
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
allowBuilds:
'@swc/core': true
core-js: true
esbuild: true
vue-demi: true
+3
View File
@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>
+15
View File
@@ -0,0 +1,15 @@
import { createApp } from 'vue';
import { createDiscreteApi, NIcon, NModal, NSpin } from 'naive-ui';
import App from './App.vue';
import router from './router';
import './mobile/styles.css';
const app = createApp(App);
const { message } = createDiscreteApi(['message']);
window.$message = message;
app.component('NIcon', NIcon);
app.component('NModal', NModal);
app.component('NSpin', NSpin);
app.use(router);
app.mount('#app');
+175
View File
@@ -0,0 +1,175 @@
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: '/app/home',
},
{
path: '/app/auth',
name: 'MobileAuth',
component: () => import('../mobile/views/AuthView.vue'),
meta: { title: '登录' },
},
{
path: '/app',
component: () => import('../mobile/MobileShell.vue'),
redirect: '/app/home',
children: [
{
path: 'home',
name: 'MobileHome',
component: () => import('../mobile/views/HomeView.vue'),
meta: { title: '首页', mobileNav: true },
},
{
path: 'orders',
name: 'MobileOrders',
component: () => import('../mobile/views/OrdersView.vue'),
meta: { title: '记录', mobileNav: true },
},
{
path: 'orders/:id',
name: 'MobileOrderDetail',
component: () => import('../mobile/views/OrdersView.vue'),
meta: { title: '详情页' },
},
{
path: 'claim',
name: 'MobileClaim',
component: () => import('../mobile/views/ClaimView.vue'),
meta: { title: '领取', mobileNav: true },
},
{
path: 'leaderboard',
name: 'MobileLeaderboard',
component: () => import('../mobile/views/LeaderboardView.vue'),
meta: { title: '荣誉榜' },
},
{
path: 'mall',
name: 'MobileMall',
component: () => import('../mobile/views/MallView.vue'),
meta: { title: '商城' },
},
{
path: 'mall/:id',
name: 'MobileProduct',
component: () => import('../mobile/views/MallView.vue'),
meta: { title: '商品详情' },
},
{
path: 'support',
name: 'MobileSupport',
component: () => import('../mobile/views/SupportView.vue'),
meta: { title: '客服中心', mobileNav: true },
},
{
path: 'support/:id',
name: 'MobileChat',
component: () => import('../mobile/views/SupportView.vue'),
meta: { title: '在线客服' },
},
{
path: 'profile',
name: 'MobileProfile',
component: () => import('../mobile/views/ProfileView.vue'),
meta: { title: '我的', mobileNav: true },
},
{
path: 'profile/info',
name: 'MobileProfileInfo',
component: () => import('../mobile/views/FormView.vue'),
meta: { title: '个人信息', form: 'profile' },
},
{
path: 'profile/bank-card',
name: 'MobileBankCard',
component: () => import('../mobile/views/FormView.vue'),
meta: { title: '添加银行卡', form: 'bank' },
},
{
path: 'profile/withdrawal',
name: 'MobileWithdrawal',
component: () => import('../mobile/views/WithdrawalView.vue'),
meta: { title: '取出' },
},
{
path: 'recharge',
name: 'MobileRecharge',
component: () => import('../mobile/views/RechargeView.vue'),
meta: { title: '储入' },
},
{
path: 'rules',
name: 'MobileRules',
component: () => import('../mobile/views/RulesView.vue'),
meta: { title: '规则说明' },
},
{
path: 'agency',
name: 'MobileAgency',
component: () => import('../mobile/views/AgencyView.vue'),
meta: { title: '代理合作' },
},
{
path: 'profile/records',
name: 'MobileFinanceRecords',
component: () => import('../mobile/views/FinanceRecordsView.vue'),
meta: { title: '记录' },
},
{
path: 'profile/password',
name: 'MobileLoginPassword',
component: () => import('../mobile/views/FormView.vue'),
meta: { title: '修改登录密码', form: 'loginPassword' },
},
{
path: 'profile/trade-password',
name: 'MobileTradePassword',
component: () => import('../mobile/views/FormView.vue'),
meta: { title: '支付密码', form: 'tradePassword' },
},
{
path: 'password/verify',
name: 'MobilePasswordVerify',
component: () => import('../mobile/views/FormView.vue'),
meta: { title: '身份验证', form: 'verify' },
},
{
path: 'password/reset',
name: 'MobilePasswordReset',
component: () => import('../mobile/views/FormView.vue'),
meta: { title: '重设密码', form: 'reset' },
},
{
path: 'content/platform_intro',
name: 'MobilePlatformIntro',
component: () => import('../mobile/views/PlatformIntroView.vue'),
meta: { title: '平台介绍' },
},
{
path: 'content/:key',
name: 'MobileContent',
component: () => import('../mobile/views/FormView.vue'),
meta: { title: '内容详情', form: 'content' },
},
],
},
{
path: '/:pathMatch(.*)*',
redirect: '/app/home',
},
];
const router = createRouter({
history: createWebHashHistory(),
routes,
scrollBehavior: () => ({ left: 0, top: 0 }),
});
router.afterEach((route) => {
document.title = route.meta.title ? `${String(route.meta.title)} - Olons` : 'Olons';
});
export default router;
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2021",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"],
"exclude": ["node_modules", "dist"]
}
+25
View File
@@ -0,0 +1,25 @@
import vue from '@vitejs/plugin-vue';
import { defineConfig, loadEnv } from 'vite';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), 'VITE_');
return {
base: env.VITE_PUBLIC_PATH || '/',
plugins: [vue()],
server: {
host: true,
port: Number(env.VITE_PORT) || 5173,
proxy: {
'/v1': {
target: env.VITE_API_TARGET || 'http://localhost:6061',
changeOrigin: true,
},
},
},
build: {
target: 'es2015',
outDir: 'dist',
},
};
});