feat: 完善移动端会员业务流程

This commit is contained in:
richjohnrambo
2026-10-13 14:19:55 +08:00
parent 32c6238751
commit b2ebea4c80
59 changed files with 1516 additions and 470 deletions
+2
View File
@@ -26,6 +26,8 @@ go.work.sum
# env file
.env
.idea/
.DS_Store
storage/uploads/
# ---> Node
# Logs
+5 -5
View File
@@ -17,16 +17,16 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=web-builder /web/dist ./web/dist
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/im_workbench ./cmd/workbench
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/olons_server ./cmd/olons_server
FROM alpine:3.22
WORKDIR /app
RUN apk add --no-cache tzdata
ENV WORKBENCH_CONFIG=/app/configs/workbench.test.json
COPY --from=builder /out/im_workbench /app/im_workbench
ENV OLONS_SERVER_CONFIG=/app/configs/olons_server.test.json
COPY --from=builder /out/olons_server /app/olons_server
COPY configs /app/configs
COPY --from=web-builder /web/dist /app/web/dist
ENV WORKBENCH_FRONTEND_DIR=/app/web/dist
ENV OLONS_SERVER_FRONTEND_DIR=/app/web/dist
EXPOSE 6061
ENTRYPOINT ["/app/im_workbench"]
ENTRYPOINT ["/app/olons_server"]
+2 -2
View File
@@ -1,13 +1,13 @@
.PHONY: build test run web-install web-build
build:
go build -o bin/im_workbench ./cmd/workbench
go build -o bin/olons_server ./cmd/olons_server
test:
go test ./...
run:
WORKBENCH_CONFIG=configs/workbench.local.json go run ./cmd/workbench
OLONS_SERVER_CONFIG=configs/olons_server.local.json go run ./cmd/olons_server
web-install:
cd web && pnpm install --frozen-lockfile
+11 -12
View File
@@ -1,8 +1,8 @@
# Workbench
# Olons Server
移动端任务与账户平台后端。项目是独立应用,业务数据以 MySQL `olons_admin` 库中的 `hg_*` 表为唯一事实源。
项目现在采用前后端一体目录结构:`web/` 是参考 `im_manager` 组织的 Vue 3 + Vite 管理端,Go 服务在生产环境通过 `/admin/` 提供构建后的静态资源,`/v1/` 继续提供业务 API。
项目现在采用前后端一体目录结构:`web/` 是 Vue 3 + Vite 移动端,Go 服务在生产环境通过网站根路径 `/` 提供构建后的静态资源,`/v1/` 继续提供业务 API。
## 当前接口
@@ -42,17 +42,16 @@
核心环境变量:
```bash
WORKBENCH_CONFIG=configs/workbench.local.json
WORKBENCH_MYSQL_DSN='user:password@tcp(host:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local'
WORKBENCH_REDIS_ADDR='127.0.0.1:6379'
WORKBENCH_DATA_ENCRYPTION_KEY='<32 字节随机密钥的 Base64>'
WORKBENCH_LOOKUP_HMAC_KEY='<至少 32 字节随机密钥的 Base64>'
WORKBENCH_API_KEYS='<API Key,多个以逗号分隔>'
OLONS_SERVER_CONFIG=configs/olons_server.local.json
OLONS_SERVER_MYSQL_DSN='user:password@tcp(host:3306)/olons_admin?parseTime=true&charset=utf8mb4&loc=Local'
OLONS_SERVER_REDIS_ADDR='127.0.0.1:6379'
OLONS_SERVER_FINANCE_MASTER_KEY='<至少 32 个字符的固定资金安全密钥>'
OLONS_SERVER_API_KEYS='<API Key,多个以逗号分隔>'
```
`WORKBENCH_DATA_ENCRYPTION_KEY` 和 `WORKBENCH_LOOKUP_HMAC_KEY` 用于保护收款账户姓名、账号、银行卡预留手机号及取出快照等支付敏感信息。两个密钥必须分开生成、通过部署 Secret 注入,并与已有数据的加密配置保持一致;会员登录手机号直接保存在 `hg_app_member.mobile`。
`finance_master_key` 已在 JSON 配置中提供固定值,也可由 `OLONS_SERVER_FINANCE_MASTER_KEY` 覆盖。服务会从这个主密钥派生数据加密键和检索键,用于保护收款账户姓名、账号、银行卡预留手机号及取出快照等支付敏感信息。密钥必须与已有数据保持一致;会员登录手机号直接保存在 `hg_app_member.mobile`。
前端静态目录可通过 `WORKBENCH_FRONTEND_DIR` 覆盖,默认值为 `web/dist`。本地开发可分别启动前后端:
前端静态目录可通过 `OLONS_SERVER_FRONTEND_DIR` 覆盖,默认值为 `web/dist`。本地开发可分别启动前后端:
```bash
make web-install
@@ -60,7 +59,7 @@ make web-build
make run
```
生产镜像使用 Docker 多阶段构建自动安装前端依赖、构建 `web/`,并将产物部署到 `/admin/`。
生产镜像使用 Docker 多阶段构建自动安装前端依赖、构建 `web/`,并将 C 端产物部署到网站根路径 `/`。
## 请求头
@@ -70,7 +69,7 @@ X-Request-ID: <request id>
X-Device-ID: <optional device id>
```
默认租户通过 `WORKBENCH_DEFAULT_TENANT_ID` 配置,现有 `olons_admin` 数据使用 `tenant_id=0`。服务不接受客户端传入租户身份。
默认租户通过 `OLONS_SERVER_DEFAULT_TENANT_ID` 配置,现有 `olons_admin` 数据使用 `tenant_id=0`。服务不接受客户端传入租户身份。
## 验证
+4 -4
View File
@@ -16,7 +16,7 @@ import (
"regexp"
"strings"
"git.118im.com/im_team/im_workbench/internal/config"
"git.118im.com/im_team/olons_server/internal/config"
_ "github.com/go-sql-driver/mysql"
)
@@ -59,9 +59,9 @@ func main() {
log.Fatal("mobile_cipher is missing before migration")
}
aeads := newAEADs(append(splitKeys(os.Getenv("WORKBENCH_MEMBER_MIGRATION_DATA_KEYS")), cfg.Auth.DataEncryptionKey))
lookupKeys := decodeKeys(append(splitKeys(os.Getenv("WORKBENCH_MEMBER_MIGRATION_LOOKUP_KEYS")), cfg.Auth.LookupHMACKey))
overrides, err := parseOverrides(os.Getenv("WORKBENCH_MEMBER_MIGRATION_OVERRIDES"))
aeads := newAEADs(append(splitKeys(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_DATA_KEYS")), cfg.Auth.DataEncryptionKey))
lookupKeys := decodeKeys(append(splitKeys(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_LOOKUP_KEYS")), cfg.Auth.LookupHMACKey))
overrides, err := parseOverrides(os.Getenv("OLONS_SERVER_MEMBER_MIGRATION_OVERRIDES"))
if err != nil {
log.Fatal(err)
}
@@ -10,8 +10,8 @@ import (
"syscall"
"time"
"git.118im.com/im_team/im_workbench/internal/app"
"git.118im.com/im_team/im_workbench/internal/config"
"git.118im.com/im_team/olons_server/internal/app"
"git.118im.com/im_team/olons_server/internal/config"
)
func main() {
@@ -34,7 +34,7 @@ func main() {
errCh := make(chan error, 1)
go func() {
log.Printf("workbench listening on %s", cfg.Listen)
log.Printf("olons_server listening on %s", cfg.Listen)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
@@ -10,13 +10,16 @@
"addr": "127.0.0.1:6379",
"db": 0
},
"upload": {
"dir": "storage/uploads",
"public_path": "/uploads"
},
"auth": {
"api_keys": [],
"session_key_pattern": "workbench:session:token:%s",
"session_key_pattern": "olons_server:session:token:%s",
"session_ttl_seconds": 1209600,
"default_tenant_id": 0,
"data_encryption_key": "",
"lookup_hmac_key": "",
"finance_master_key": "olons-finance-master-key-v1-20261013",
"allow_dev_header_auth": true,
"required_api_key": false
}
@@ -10,13 +10,16 @@
"addr": "",
"db": 0
},
"upload": {
"dir": "storage/uploads",
"public_path": "/uploads"
},
"auth": {
"api_keys": [],
"session_key_pattern": "workbench:session:token:%s",
"session_key_pattern": "olons_server:session:token:%s",
"session_ttl_seconds": 1209600,
"default_tenant_id": 0,
"data_encryption_key": "",
"lookup_hmac_key": "",
"finance_master_key": "olons-finance-master-key-v1-20261013",
"required_api_key": true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 718 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 KiB

@@ -2,12 +2,12 @@
> 版本:V1.0
> 更新日期:2026-10-09
> 适用项目:`im_workbench`
> 适用项目:`olons_server`
> 数据源:MySQL `olons_admin` 中的 `hg_*` 业务表
## 1. 文档说明
本文档用于移动端与 Workbench 后端联调。内容依据《移动端任务与账户平台需求文档》、当前代码实现和现有数据库表结构整理。
本文档用于移动端与 Olons Server 后端联调。内容依据《移动端任务与账户平台需求文档》、当前代码实现和现有数据库表结构整理。
接口分为两类:
@@ -303,7 +303,7 @@ PATCH /v1/me/avatar
}
```
附件必须是 `app_id=workbench`、归属当前会员、状态有效且 MIME 类型为图片的 `hg_sys_attachment` 记录。上传接口未实现前,前端不能自行生成可用附件 ID。
附件必须是 `app_id=olons_server`、归属当前会员、状态有效且 MIME 类型为图片的 `hg_sys_attachment` 记录。上传接口未实现前,前端不能自行生成可用附件 ID。
成功响应 `data`:
@@ -581,7 +581,7 @@ POST /v1/task-orders/claim
成功返回订单摘要,包括 `id`、`order_no`、`status`、`product`、`total_amount`、`commission_amount`、`currency` 和 `deadline_at`。
领单事务会锁定当前会员和钱包记录,重新校验大厅等级、账户状态、最低余额、当日次数、未完成订单、任务发布与有效期、任务每日上限及单会员上限。当前版本在事务内直接选中可用任务,创建 `status=pending` 的订单并记录 `matched_at`,客户端收到成功响应后即可进入订单详情并提交凭证;不依赖额外匹配进程。领取不扣款、不冻结余额,也不生成钱包流水。当前表结构没有任务与商品的直接绑定字段,因此 `product.id`、`total_amount` 和 `unit_price_amount` 允许为 `0`,任务名称和封面取自 `hg_task`。
领单事务会锁定当前会员和钱包记录,重新校验大厅等级、账户状态、最低余额、会员等级每日领取次数和未完成订单。校验通过后先创建 `status=matching`、`order_type=2` 的派单中订单,此时商品、数量、金额和佣金均为空或 `0`,与后台订单列表的“派单中/匹配订单”原型一致。会员的 `auto_dispatch=1` 时,达到网站设置的 `autoOrderDelaySeconds` 延时后由系统自动匹配:从当前已发布、在有效期内、币种为 CNY,且价格同时满足会员可用余额与 `autoOrderMinAmount`/`autoOrderMaxAmount` 区间的 `hg_product` 中随机选取商品,按会员当前等级版本计算佣金,并将订单原子更新为 `status=pending`。匹配成功时在同一事务内从可用余额扣除订单本金并转入冻结余额,同时生成可用余额支出和冻结余额收入流水。订单完成时释放冻结本金、返回可用余额并另行入账佣金;订单超时时也必须释放并退回本金。`auto_dispatch=2` 或自动匹配暂时找不到商品时,订单保持“派单中”,管理员可在后台通过“匹配订单”人工指定商品,人工匹配也执行相同的本金扣除和冻结流程。自动和人工匹配共用同一状态条件,并发时只有一方能更新成功。新注册会员默认开启自动派单,管理员可在会员编辑弹窗修改派单模式。
| HTTP | `reason` | 说明 |
| --- | --- | --- |
@@ -642,15 +642,12 @@ POST /v1/task-orders/{order_id}/submissions
```
```json
{
"evidence_text": "已完成任务",
"evidence_attachment_ids": [1001, 1002]
}
{}
```
成功返回 `submission_no`、`status=reviewing`、`submitted_at` 和最新订单状态。重复提交不得生成多笔审核记录或佣金流水。
成功返回 `submission_no`、`status=settled`、`submitted_at` 和 `order_status=completed`。重复提交不得生成多笔提交记录或佣金流水。
仅 `pending` 订单允许提交。`evidence_text` 去除首尾空白后最长 2000 字符,附件最多 9 个,两者至少提供一项;附件必须属于当前租户和当前会员、`app_id=workbench`、状态有效,且类型为图片或 PDF。提交事务对订单加锁,超时订单自动置为 `expired`,有效提交创建唯一审核记录并把订单置为 `processing`。此步骤不生成佣金流水;审核通过并结算仍由后台流程负责。
正常情况下仅 `pending` 订单允许提交。前端按原型提供“立即提交”确认操作,不要求用户填写凭证。接口仍兼容可选的 `evidence_text` 和 `evidence_attachment_ids`:文字最长 2000 字符、附件最多 9 个;附件必须属于当前租户和当前会员、`app_id=olons_server`、状态有效,且类型为图片或 PDF。提交事务对订单、钱包加锁,超时订单自动置为 `expired`;有效提交创建唯一的已结算记录、佣金流水并增加可用余额,最后把订单置为 `completed`。后台审核页保留已结算记录供查询,冻结、强制处理和人工补偿只作为异常兜底。
订单详情返回:
@@ -659,7 +656,7 @@ POST /v1/task-orders/{order_id}/submissions
- `submission`:凭证、审核状态与驳回原因;
- `timeline`:创建、匹配、提交、审核、结算、完成时间点;
- `ledgers`:以订单号或提交号关联的钱包流水;
- `allowed_actions`:待处理为 `submit`、`contact_support`,审核中/超时/冻结/失败仅为 `contact_support`。
- `allowed_actions`:待处理为 `submit`、`contact_support`,超时/冻结/失败仅为 `contact_support`;提交成功后不再提供重复提交操作。
### 7.6 任务统计
@@ -857,7 +854,7 @@ GET /v1/wallet/ledgers?page=1&page_size=20&biz_type=task&direction=income
响应包含 `recharge_no`、`status`、`request_amount`、`payable_amount`、`currency`、`expire_at` 和经过校验的支付指引。不通过客服聊天发送临时个人收款账户。
公开状态:`pending_payment`、`reviewing`、`processing`、`succeeded`、`failed`、`expired`、`cancelled`。失败、驳回或过期必须返回 `reason_code` 和 `reason_text`。
公开状态:`pending_payment`、`reviewing`、`processing`、`succeeded`、`failed`、`expired`、`cancelled`。当前人工审核流程创建后直接进入 `reviewing`;`pending_payment` 仅为后续接入真实支付通道和支付回调时保留。失败、驳回或过期必须返回 `reason_code` 和 `reason_text`。
## 11. 收款账户和取出(已实现,沙箱)
@@ -870,36 +867,34 @@ GET /v1/wallet/ledgers?page=1&page_size=20&biz_type=task&direction=income
```http
GET /v1/payment-accounts
POST /v1/payment-accounts
POST /v1/attachments/images
```
新增请求:
先分别上传微信和支付宝二维码。图片接口使用 `multipart/form-data`,字段名为 `file`,仅接受不超过 5MB 的 JPG、PNG、WebP,返回附件 `id` 和可访问的 `url`。随后提交新增请求:
```json
{
"account_type": "bank_card",
"bank_code": "ICBC",
"bank_name": "中国工商银行",
"holder_name": "张三",
"identity_no": "11010119900101123X",
"account_no": "6222000000001234",
"reserved_phone": "13800138000",
"qr_attachment_id": 0,
"trade_password": "654321"
"wechat_qr_attachment_id": 101,
"alipay_qr_attachment_id": 102
}
```
支持的账户类型:
绑卡页面按原型固定为银行卡账户,字段规则如下:
| `account_type` | 必填字段 | 说明 |
| 字段 | 是否必填 | 说明 |
| --- | --- | --- |
| `bank_card` | `bank_code`、`bank_name`、`holder_name`、`account_no` | 卡号只允许 8–32 位数字,空格和连字符会被移除 |
| `digital_wallet` | `holder_name`、`account_no` | 通用数字钱包 |
| `unionpay` | `holder_name`、`account_no` | 银联账户 |
| `alipay` | `holder_name`、`account_no` | 后端固定渠道名称和编码 |
| `wechat` | `holder_name`、`account_no` | 后端固定渠道名称和编码 |
| `holder_name` | 是 | 2–64 个字符 |
| `identity_no` | 是 | 15 位或 18 位身份证号,18 位末位允许 X |
| `account_no` | 是 | 8–32 位数字,空格和连字符会被移除 |
| `bank_name` | 是 | 2–100 个字符 |
| `wechat_qr_attachment_id` | 是 | 当前会员上传的有效图片附件 |
| `alipay_qr_attachment_id` | 是 | 当前会员上传的有效图片附件,不能与微信二维码共用同一附件 |
`reserved_phone` 可选,传入时必须为 11 位中国大陆手机号;`qr_attachment_id` 可选,传入时必须是当前会员在 `workbench` 应用下有效的图片附件。绑定前必须校验 6 位数字交易密码,连续错误 5 次后锁定 15 分钟。
姓名、账号和手机号使用 AES-256-GCM 独立随机 nonce 加密,账号另存 HMAC-SHA256 检索哈希用于防重复。列表只返回脱敏信息,绝不返回明文或密文。新增账户初始 `verify_status=pending`,必须由后台核验为 `verified` 后才能用于取出。
姓名、身份证号和银行卡号使用 AES-256-GCM 独立随机 nonce 加密,身份证号和银行卡号另存 HMAC-SHA256 检索哈希。列表只返回脱敏信息,绝不返回明文或密文。绑定不要求支付密码;支付密码只在取出提交时校验。当前产品不设置银行卡后台审核流程,绑定成功后直接写入 `verify_status=verified`,可立即用于取出。
列表项示例:
@@ -907,13 +902,14 @@ POST /v1/payment-accounts
{
"id": 1,
"account_type": "bank_card",
"bank_code": "ICBC",
"bank_code": "BANK",
"bank_name": "中国工商银行",
"holder_mask": "张*",
"mobile_mask": "138****8000",
"identity_no_mask": "1101**********123X",
"account_no_mask": "6222********1234",
"qr_attachment_id": 0,
"verify_status": "pending",
"wechat_qr_attachment_id": 101,
"alipay_qr_attachment_id": 102,
"verify_status": "verified",
"is_default": true,
"created_at": "2026-10-09T10:00:00Z",
"updated_at": "2026-10-09T10:00:00Z"
@@ -111,9 +111,9 @@
| **状态** | **用户可见文案** | **允许操作** | **系统约束** |
| --- | --- | --- | --- |
| 匹配中 | 正在匹配任务 | 取消等待 | 只允许一个匹配请求 |
| 待处理 | 请在截止时间前完成 | 查看详情、提交 | 倒计时与服务端时间一致 |
| 处理中 | 已提交,等待确认 | 查看详情、联系客服 | 防重复提交 |
| 已完成 | 本金与佣金已入账 | 查看流水 | 三笔或组合流水必须可核对 |
| 待处理 | 请在截止时间前完成 | 查看详情、立即提交 | 倒计时与服务端时间一致 |
| 处理中 | 系统正在结算 | 查看详情、联系客服 | 防重复提交;正常情况为瞬时状态 |
| 已完成 | 佣金已入账 | 查看流水 | 订单、提交记录和佣金流水必须可核对 |
| 超时或冻结 | 显示原因与处理路径 | 申诉、联系客服 | 需要人工复核和审计 |
| 失败或取消 | 显示失败原因 | 重新领取 | 不得产生不一致余额 |
+1 -1
View File
@@ -1,4 +1,4 @@
module git.118im.com/im_team/im_workbench
module git.118im.com/im_team/olons_server
go 1.26.0
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"errors"
"testing"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/store"
"golang.org/x/crypto/bcrypt"
)
+3 -3
View File
@@ -13,8 +13,8 @@ import (
"strings"
"time"
"git.118im.com/im_team/im_workbench/internal/config"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/config"
"git.118im.com/im_team/olons_server/internal/store"
"github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt"
)
@@ -355,7 +355,7 @@ func (s *Service) issueSession(ctx context.Context, account *store.Account, devi
func (s *Service) loginRateKey(phone string) string {
digest := sha256.Sum256([]byte(phone))
return "workbench:auth:login-rate:" + strconv.FormatInt(s.cfg.DefaultTenantID, 10) + ":" + hex.EncodeToString(digest[:])
return "olons_server:auth:login-rate:" + strconv.FormatInt(s.cfg.DefaultTenantID, 10) + ":" + hex.EncodeToString(digest[:])
}
func (s *Service) recordLoginFailure(ctx context.Context, key string) {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/store"
)
type fakeRepository struct{}
+17 -14
View File
@@ -8,15 +8,16 @@ import (
"strings"
"time"
"git.118im.com/im_team/im_workbench/internal/accounts"
"git.118im.com/im_team/im_workbench/internal/auth"
"git.118im.com/im_team/im_workbench/internal/config"
"git.118im.com/im_team/im_workbench/internal/finance"
"git.118im.com/im_team/im_workbench/internal/httpapi"
"git.118im.com/im_team/im_workbench/internal/mall"
"git.118im.com/im_team/im_workbench/internal/portal"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/im_workbench/internal/tasks"
"git.118im.com/im_team/olons_server/internal/accounts"
"git.118im.com/im_team/olons_server/internal/attachments"
"git.118im.com/im_team/olons_server/internal/auth"
"git.118im.com/im_team/olons_server/internal/config"
"git.118im.com/im_team/olons_server/internal/finance"
"git.118im.com/im_team/olons_server/internal/httpapi"
"git.118im.com/im_team/olons_server/internal/mall"
"git.118im.com/im_team/olons_server/internal/portal"
"git.118im.com/im_team/olons_server/internal/store"
"git.118im.com/im_team/olons_server/internal/tasks"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/redis/go-redis/v9"
)
@@ -26,6 +27,7 @@ type App struct {
redis *redis.Client
api *httpapi.API
frontendPath string
upload config.UploadConfig
}
func New(cfg *config.Config) (*App, error) {
@@ -49,14 +51,15 @@ func New(cfg *config.Config) (*App, error) {
taskService := tasks.New(st)
mallService := mall.New(st)
financeService := finance.New(st, redisClient, cfg.Auth)
return &App{store: st, redis: redisClient, api: httpapi.New(authenticator, accountService, portalService, taskService, mallService, financeService), frontendPath: cfg.FrontendDir}, nil
attachmentService := attachments.New(st, cfg.Upload)
return &App{store: st, redis: redisClient, api: httpapi.New(authenticator, accountService, portalService, taskService, mallService, financeService, attachmentService), frontendPath: cfg.FrontendDir, upload: cfg.Upload}, nil
}
func (a *App) Handler() http.Handler {
mux := http.NewServeMux()
mux.Handle(strings.TrimRight(a.upload.PublicPath, "/")+"/", http.StripPrefix(strings.TrimRight(a.upload.PublicPath, "/"), http.FileServer(http.Dir(a.upload.Dir))))
if a.frontendDir() != "" {
mux.Handle("/admin", frontendHandler(a.frontendDir()))
mux.Handle("/admin/", frontendHandler(a.frontendDir()))
mux.Handle("/", frontendHandler(a.frontendDir()))
}
mux.HandleFunc("/healthz", a.api.Healthz)
mux.Handle("/metrics", promhttp.Handler())
@@ -65,7 +68,7 @@ func (a *App) Handler() http.Handler {
}
func (a *App) frontendDir() string {
if dir := strings.TrimSpace(os.Getenv("WORKBENCH_FRONTEND_DIR")); dir != "" {
if dir := strings.TrimSpace(os.Getenv("OLONS_SERVER_FRONTEND_DIR")); dir != "" {
return dir
}
return strings.TrimSpace(a.frontendPath)
@@ -75,7 +78,7 @@ func frontendHandler(dir string) http.Handler {
files := http.FileServer(http.Dir(dir))
index := filepath.Join(dir, "index.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/admin")
path := r.URL.Path
if path == "" {
path = "/"
}
+73
View File
@@ -0,0 +1,73 @@
package attachments
import (
"context"
"crypto/md5"
"crypto/rand"
"encoding/hex"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"git.118im.com/im_team/olons_server/internal/config"
"git.118im.com/im_team/olons_server/internal/store"
)
const maxImageSize = 5 << 20
var ErrImageInvalid = errors.New("image attachment invalid")
type Repository interface {
CreateAttachment(context.Context, store.AttachmentCreation) (*store.Attachment, error)
}
type Service struct {
repository Repository
cfg config.UploadConfig
}
func New(repository Repository, cfg config.UploadConfig) *Service {
return &Service{repository: repository, cfg: cfg}
}
func (s *Service) UploadImage(ctx context.Context, memberID int64, name string, reader io.Reader) (*store.Attachment, error) {
if s.repository == nil || memberID <= 0 {
return nil, ErrImageInvalid
}
data, err := io.ReadAll(io.LimitReader(reader, maxImageSize+1))
if err != nil || len(data) == 0 || len(data) > maxImageSize {
return nil, ErrImageInvalid
}
mimeType := http.DetectContentType(data)
extensions := map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
ext, ok := extensions[mimeType]
if !ok {
return nil, ErrImageInvalid
}
random := make([]byte, 16)
if _, err = rand.Read(random); err != nil {
return nil, err
}
fileName := hex.EncodeToString(random) + ext
if err = os.MkdirAll(s.cfg.Dir, 0o750); err != nil {
return nil, err
}
path := filepath.Join(s.cfg.Dir, fileName)
if err = os.WriteFile(path, data, 0o640); err != nil {
return nil, err
}
digest := md5.Sum(data)
publicPath := strings.TrimRight(s.cfg.PublicPath, "/") + "/" + fileName
attachment, err := s.repository.CreateAttachment(ctx, store.AttachmentCreation{
MemberID: memberID, Name: filepath.Base(name), MimeType: mimeType, Path: path,
URL: publicPath, Size: int64(len(data)), Ext: strings.TrimPrefix(ext, "."), MD5: hex.EncodeToString(digest[:]),
})
if err != nil {
_ = os.Remove(path)
return nil, err
}
return attachment, nil
}
+2 -2
View File
@@ -13,7 +13,7 @@ import (
"strings"
"time"
"git.118im.com/im_team/im_workbench/internal/config"
"git.118im.com/im_team/olons_server/internal/config"
"github.com/redis/go-redis/v9"
)
@@ -170,7 +170,7 @@ func (a *Authenticator) validateMemberSession(ctx context.Context, session *Sess
}
func memberRevokedBeforeKey(tenantID, memberID int64) string {
return "workbench:session:member-revoked-before:" + strconv.FormatInt(tenantID, 10) + ":" + strconv.FormatInt(memberID, 10)
return "olons_server:session:member-revoked-before:" + strconv.FormatInt(tenantID, 10) + ":" + strconv.FormatInt(memberID, 10)
}
func sessionKeyFromToken(pattern, token string) (string, error) {
+3 -3
View File
@@ -9,18 +9,18 @@ import (
func TestSessionKeyFromToken(t *testing.T) {
token := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
key, err := sessionKeyFromToken("workbench:session:token:%s", token)
key, err := sessionKeyFromToken("olons_server:session:token:%s", token)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(key, "workbench:session:token:") || len(key) != len("workbench:session:token:")+64 {
if !strings.HasPrefix(key, "olons_server:session:token:") || len(key) != len("olons_server:session:token:")+64 {
t.Fatalf("unexpected session key %q", key)
}
}
func TestSessionKeyFromTokenRejectsMalformedToken(t *testing.T) {
for _, token := range []string{"", "invalid", base64.RawURLEncoding.EncodeToString(make([]byte, 31))} {
if _, err := sessionKeyFromToken("workbench:session:token:%s", token); !errors.Is(err, ErrAuthRequired) {
if _, err := sessionKeyFromToken("olons_server:session:token:%s", token); !errors.Is(err, ErrAuthRequired) {
t.Errorf("token %q: error = %v, want %v", token, err, ErrAuthRequired)
}
}
+33 -19
View File
@@ -18,10 +18,16 @@ type Config struct {
MySQL MySQLConfig `json:"mysql"`
Redis RedisConfig `json:"redis"`
Auth AuthConfig `json:"auth"`
Upload UploadConfig `json:"upload"`
ReadTimeout time.Duration `json:"-"`
WriteTimeout time.Duration `json:"-"`
}
type UploadConfig struct {
Dir string `json:"dir"`
PublicPath string `json:"public_path"`
}
type MySQLConfig struct {
DSN string `json:"dsn"`
MaxOpenConns int `json:"max_open_conns"`
@@ -42,13 +48,14 @@ type AuthConfig struct {
DefaultTenantID int64 `json:"default_tenant_id"`
DataEncryptionKey string `json:"data_encryption_key"`
LookupHMACKey string `json:"lookup_hmac_key"`
FinanceMasterKey string `json:"finance_master_key"`
AllowDevHeaderAuth bool `json:"allow_dev_header_auth"`
RequiredAPIKey bool `json:"required_api_key"`
}
func Load() (*Config, error) {
cfg := defaultConfig()
if path := strings.TrimSpace(os.Getenv("WORKBENCH_CONFIG")); path != "" {
if path := strings.TrimSpace(os.Getenv("OLONS_SERVER_CONFIG")); path != "" {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
@@ -75,34 +82,38 @@ func defaultConfig() *Config {
},
Redis: RedisConfig{Addr: "127.0.0.1:6379"},
Auth: AuthConfig{
SessionKeyPattern: "workbench:session:token:%s",
SessionKeyPattern: "olons_server:session:token:%s",
SessionTTLSeconds: 14 * 24 * 60 * 60,
DefaultTenantID: 0,
RequiredAPIKey: true,
},
Upload: UploadConfig{Dir: "storage/uploads", PublicPath: "/uploads"},
ReadTimeout: 8 * time.Second,
WriteTimeout: 8 * time.Second,
}
}
func applyEnv(cfg *Config) {
setString(&cfg.Listen, "WORKBENCH_LISTEN")
setString(&cfg.FrontendDir, "WORKBENCH_FRONTEND_DIR")
setString(&cfg.MySQL.DSN, "WORKBENCH_MYSQL_DSN")
setInt(&cfg.MySQL.MaxOpenConns, "WORKBENCH_MYSQL_MAX_OPEN_CONNS")
setInt(&cfg.MySQL.MaxIdleConns, "WORKBENCH_MYSQL_MAX_IDLE_CONNS")
setString(&cfg.Redis.Addr, "WORKBENCH_REDIS_ADDR")
setString(&cfg.Redis.Username, "WORKBENCH_REDIS_USERNAME")
setString(&cfg.Redis.Password, "WORKBENCH_REDIS_PASSWORD")
setInt(&cfg.Redis.DB, "WORKBENCH_REDIS_DB")
setString(&cfg.Auth.SessionKeyPattern, "WORKBENCH_SESSION_KEY_PATTERN")
setInt(&cfg.Auth.SessionTTLSeconds, "WORKBENCH_SESSION_TTL_SECONDS")
setInt64(&cfg.Auth.DefaultTenantID, "WORKBENCH_DEFAULT_TENANT_ID")
setString(&cfg.Auth.DataEncryptionKey, "WORKBENCH_DATA_ENCRYPTION_KEY")
setString(&cfg.Auth.LookupHMACKey, "WORKBENCH_LOOKUP_HMAC_KEY")
setBool(&cfg.Auth.AllowDevHeaderAuth, "WORKBENCH_ALLOW_DEV_HEADER_AUTH")
setBool(&cfg.Auth.RequiredAPIKey, "WORKBENCH_REQUIRED_API_KEY")
if keys := strings.TrimSpace(os.Getenv("WORKBENCH_API_KEYS")); keys != "" {
setString(&cfg.Listen, "OLONS_SERVER_LISTEN")
setString(&cfg.FrontendDir, "OLONS_SERVER_FRONTEND_DIR")
setString(&cfg.MySQL.DSN, "OLONS_SERVER_MYSQL_DSN")
setInt(&cfg.MySQL.MaxOpenConns, "OLONS_SERVER_MYSQL_MAX_OPEN_CONNS")
setInt(&cfg.MySQL.MaxIdleConns, "OLONS_SERVER_MYSQL_MAX_IDLE_CONNS")
setString(&cfg.Redis.Addr, "OLONS_SERVER_REDIS_ADDR")
setString(&cfg.Redis.Username, "OLONS_SERVER_REDIS_USERNAME")
setString(&cfg.Redis.Password, "OLONS_SERVER_REDIS_PASSWORD")
setInt(&cfg.Redis.DB, "OLONS_SERVER_REDIS_DB")
setString(&cfg.Auth.SessionKeyPattern, "OLONS_SERVER_SESSION_KEY_PATTERN")
setInt(&cfg.Auth.SessionTTLSeconds, "OLONS_SERVER_SESSION_TTL_SECONDS")
setInt64(&cfg.Auth.DefaultTenantID, "OLONS_SERVER_DEFAULT_TENANT_ID")
setString(&cfg.Auth.DataEncryptionKey, "OLONS_SERVER_DATA_ENCRYPTION_KEY")
setString(&cfg.Auth.LookupHMACKey, "OLONS_SERVER_LOOKUP_HMAC_KEY")
setString(&cfg.Auth.FinanceMasterKey, "OLONS_SERVER_FINANCE_MASTER_KEY")
setBool(&cfg.Auth.AllowDevHeaderAuth, "OLONS_SERVER_ALLOW_DEV_HEADER_AUTH")
setBool(&cfg.Auth.RequiredAPIKey, "OLONS_SERVER_REQUIRED_API_KEY")
setString(&cfg.Upload.Dir, "OLONS_SERVER_UPLOAD_DIR")
setString(&cfg.Upload.PublicPath, "OLONS_SERVER_UPLOAD_PUBLIC_PATH")
if keys := strings.TrimSpace(os.Getenv("OLONS_SERVER_API_KEYS")); keys != "" {
cfg.Auth.APIKeys = splitCSV(keys)
}
}
@@ -133,6 +144,9 @@ func (c *Config) validate() error {
if c.Auth.DefaultTenantID < 0 {
return errors.New("default tenant id must not be negative")
}
if strings.TrimSpace(c.Upload.Dir) == "" || !strings.HasPrefix(c.Upload.PublicPath, "/") {
return errors.New("upload config is invalid")
}
return nil
}
+67 -84
View File
@@ -19,8 +19,8 @@ import (
"unicode"
"unicode/utf8"
"git.118im.com/im_team/im_workbench/internal/config"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/config"
"git.118im.com/im_team/olons_server/internal/store"
"github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt"
)
@@ -34,10 +34,8 @@ const (
var (
tradePasswordPattern = regexp.MustCompile(`^[0-9]{6}$`)
phonePattern = regexp.MustCompile(`^1[3-9][0-9]{9}$`)
bankCodePattern = regexp.MustCompile(`^[A-Z0-9_-]{2,32}$`)
accountTokenPattern = regexp.MustCompile(`^[A-Za-z0-9@._+\-]{5,64}$`)
idempotencyPattern = regexp.MustCompile(`^[A-Za-z0-9._:\-]{8,80}$`)
identityNoPattern = regexp.MustCompile(`^[1-9][0-9]{14}(?:[0-9]{2}[0-9X])?$`)
)
type Repository interface {
@@ -68,14 +66,12 @@ type Service struct {
}
type PaymentAccountInput struct {
AccountType string `json:"account_type"`
BankCode string `json:"bank_code"`
BankName string `json:"bank_name"`
HolderName string `json:"holder_name"`
IdentityNo string `json:"identity_no"`
AccountNo string `json:"account_no"`
ReservedPhone string `json:"reserved_phone"`
QRAttachmentID int64 `json:"qr_attachment_id"`
TradePassword string `json:"trade_password"`
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
AlipayQRAttachmentID int64 `json:"alipay_qr_attachment_id"`
}
type PaymentAccount struct {
@@ -84,9 +80,10 @@ type PaymentAccount struct {
BankCode string `json:"bank_code"`
BankName string `json:"bank_name"`
HolderMask string `json:"holder_mask"`
MobileMask string `json:"mobile_mask"`
IdentityNoMask string `json:"identity_no_mask"`
AccountNoMask string `json:"account_no_mask"`
QRAttachmentID int64 `json:"qr_attachment_id"`
WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"`
AlipayQRAttachmentID int64 `json:"alipay_qr_attachment_id"`
VerifyStatus string `json:"verify_status"`
IsDefault bool `json:"is_default"`
CreatedAt string `json:"created_at,omitempty"`
@@ -235,11 +232,19 @@ type WalletLedgerPage struct {
func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service {
service := &Service{repository: repository, redis: redisClient, now: time.Now}
encryptionKey, encryptionErr := decodeKey(cfg.DataEncryptionKey)
lookupKey, lookupErr := decodeKey(cfg.LookupHMACKey)
masterKey := strings.TrimSpace(cfg.FinanceMasterKey)
var encryptionKey, lookupKey []byte
if len([]byte(masterKey)) >= 32 {
encryptionKey = deriveKey(masterKey, "data-encryption")
lookupKey = deriveKey(masterKey, "lookup-hmac")
} else {
var encryptionErr, lookupErr error
encryptionKey, encryptionErr = decodeKey(cfg.DataEncryptionKey)
lookupKey, lookupErr = decodeKey(cfg.LookupHMACKey)
if encryptionErr != nil || len(encryptionKey) != 32 || lookupErr != nil || len(lookupKey) < 32 {
return service
}
}
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return service
@@ -254,6 +259,12 @@ func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig
return service
}
func deriveKey(masterKey, purpose string) []byte {
mac := hmac.New(sha256.New, []byte(masterKey))
_, _ = mac.Write([]byte("olons-finance:" + purpose + ":v1"))
return mac.Sum(nil)
}
func (s *Service) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
if s.repository == nil {
return nil, ErrUnavailable
@@ -273,36 +284,29 @@ func (s *Service) CreatePaymentAccount(ctx context.Context, tenantID, memberID i
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 {
if err := normalizePaymentAccount(&input); err != nil {
return nil, err
}
holderCipher, err := s.encrypt(input.HolderName)
if err != nil {
return nil, err
}
identityNoCipher, err := s.encrypt(input.IdentityNo)
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,
TenantID: tenantID, MemberID: memberID, AccountType: 1, BankCode: "BANK", BankName: input.BankName,
HolderCipher: holderCipher, HolderMask: maskName(input.HolderName),
IdentityNoCipher: identityNoCipher, IdentityNoHash: s.lookupHash(input.IdentityNo),
IdentityNoMask: maskIdentityNo(input.IdentityNo), AccountNoCipher: accountCipher,
AccountNoHash: s.lookupHash(input.AccountNo), AccountNoMask: maskAccount(input.AccountNo),
QRAttachmentID: input.QRAttachmentID,
WechatQRAttachmentID: input.WechatQRAttachmentID,
AlipayQRAttachmentID: input.AlipayQRAttachmentID,
})
if err != nil {
return nil, err
@@ -349,9 +353,9 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
if err != nil {
return nil, store.ErrPaymentAccountInvalid
}
reservedPhone := ""
if account.MobileCipher != "" {
reservedPhone, err = s.decrypt(account.MobileCipher)
identityNo := ""
if account.IdentityNoCipher != "" {
identityNo, err = s.decrypt(account.IdentityNoCipher)
if err != nil {
return nil, store.ErrPaymentAccountInvalid
}
@@ -359,8 +363,9 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
snapshot, err := json.Marshal(map[string]any{
"account_type": accountTypeName(account.AccountType), "bank_code": account.BankCode,
"bank_name": account.BankName, "holder_name": holderName,
"reserved_phone": reservedPhone, "account_no": accountNo,
"qr_attachment_id": account.QRAttachmentID,
"identity_no": identityNo, "account_no": accountNo,
"wechat_qr_attachment_id": account.WechatQRAttachmentID,
"alipay_qr_attachment_id": account.AlipayQRAttachmentID,
})
if err != nil {
return nil, err
@@ -375,8 +380,10 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64
WithdrawalNo: withdrawalNo, PaymentAccountID: account.ID,
ExpectedAccountType: account.AccountType, ExpectedBankCode: account.BankCode,
ExpectedBankName: account.BankName, ExpectedHolderCipher: account.HolderCipher,
ExpectedMobileCipher: account.MobileCipher, ExpectedAccountNoCipher: account.AccountNoCipher,
ExpectedAccountNoHash: account.AccountNoHash, ExpectedQRAttachmentID: account.QRAttachmentID,
ExpectedIdentityNoCipher: account.IdentityNoCipher, ExpectedIdentityNoHash: account.IdentityNoHash,
ExpectedAccountNoCipher: account.AccountNoCipher, ExpectedAccountNoHash: account.AccountNoHash,
ExpectedWechatQRAttachmentID: account.WechatQRAttachmentID,
ExpectedAlipayQRAttachmentID: account.AlipayQRAttachmentID,
Channel: *channel,
RequestAmount: quote.RequestAmount, FeeAmount: quote.FeeAmount, ActualAmount: quote.ActualAmount,
Currency: quote.Currency, AccountSnapshotCipher: snapshotCipher, AccountMask: account.AccountNoMask,
@@ -695,7 +702,7 @@ func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID in
if !tradePasswordPattern.MatchString(strings.TrimSpace(password)) {
return nil, ErrTradePassword
}
key := fmt.Sprintf("workbench:finance:trade-password-rate:%d:%d", tenantID, memberID)
key := fmt.Sprintf("olons_server:finance:trade-password-rate:%d:%d", tenantID, memberID)
attempts, err := s.redis.Get(ctx, key).Int()
if err != nil && !errors.Is(err, redis.Nil) {
return nil, ErrUnavailable
@@ -725,57 +732,26 @@ func (s *Service) verifyTradePassword(ctx context.Context, tenantID, memberID in
return security, nil
}
func normalizePaymentAccount(input *PaymentAccountInput) (int, error) {
input.AccountType = strings.ToLower(strings.TrimSpace(input.AccountType))
input.BankCode = strings.ToUpper(strings.TrimSpace(input.BankCode))
func normalizePaymentAccount(input *PaymentAccountInput) error {
input.BankName = strings.TrimSpace(input.BankName)
input.HolderName = strings.TrimSpace(input.HolderName)
input.AccountNo = strings.TrimSpace(input.AccountNo)
input.ReservedPhone = strings.TrimSpace(input.ReservedPhone)
if !validHumanName(input.HolderName) || (input.ReservedPhone != "" && !phonePattern.MatchString(input.ReservedPhone)) || input.QRAttachmentID < 0 {
return 0, ErrAccountFieldsInvalid
}
switch input.AccountType {
case "bank_card":
input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(input.AccountNo)
if !bankCodePattern.MatchString(input.BankCode) || utf8.RuneCountInString(input.BankName) < 2 ||
utf8.RuneCountInString(input.BankName) > 100 || len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) {
return 0, ErrAccountFieldsInvalid
}
return 1, nil
case "digital_wallet":
if !accountTokenPattern.MatchString(input.AccountNo) {
return 0, ErrAccountFieldsInvalid
}
return 2, nil
case "unionpay":
if !accountTokenPattern.MatchString(input.AccountNo) {
return 0, ErrAccountFieldsInvalid
}
return 3, nil
case "alipay":
input.BankName = "支付宝"
input.BankCode = "ALIPAY"
if !accountTokenPattern.MatchString(input.AccountNo) {
return 0, ErrAccountFieldsInvalid
}
return 4, nil
case "wechat":
input.BankName = "微信"
input.BankCode = "WECHAT"
if !accountTokenPattern.MatchString(input.AccountNo) {
return 0, ErrAccountFieldsInvalid
}
return 5, nil
default:
return 0, ErrAccountTypeInvalid
input.IdentityNo = strings.ToUpper(strings.NewReplacer(" ", "", "-", "").Replace(input.IdentityNo))
input.AccountNo = strings.NewReplacer(" ", "", "-", "").Replace(strings.TrimSpace(input.AccountNo))
if !validHumanName(input.HolderName) || !identityNoPattern.MatchString(input.IdentityNo) ||
utf8.RuneCountInString(input.BankName) < 2 || utf8.RuneCountInString(input.BankName) > 100 ||
len(input.AccountNo) < 8 || len(input.AccountNo) > 32 || !allDigits(input.AccountNo) ||
input.WechatQRAttachmentID <= 0 || input.AlipayQRAttachmentID <= 0 ||
input.WechatQRAttachmentID == input.AlipayQRAttachmentID {
return ErrAccountFieldsInvalid
}
return nil
}
func paymentAccountFromStore(row store.PaymentAccount) PaymentAccount {
return PaymentAccount{ID: row.ID, AccountType: accountTypeName(row.AccountType), BankCode: row.BankCode,
BankName: row.BankName, HolderMask: row.HolderMask, MobileMask: row.MobileMask,
AccountNoMask: row.AccountNoMask, QRAttachmentID: row.QRAttachmentID,
BankName: row.BankName, HolderMask: row.HolderMask, IdentityNoMask: row.IdentityNoMask,
AccountNoMask: row.AccountNoMask, WechatQRAttachmentID: row.WechatQRAttachmentID,
AlipayQRAttachmentID: row.AlipayQRAttachmentID,
VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1,
CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
}
@@ -1023,6 +999,13 @@ func maskAccount(value string) string {
return string(runes[:prefix]) + strings.Repeat("*", len(runes)-prefix-4) + string(runes[len(runes)-4:])
}
func maskIdentityNo(value string) string {
if len(value) <= 8 {
return strings.Repeat("*", len(value))
}
return value[:4] + strings.Repeat("*", len(value)-8) + value[len(value)-4:]
}
func validHumanName(value string) bool {
count := utf8.RuneCountInString(value)
if count < 2 || count > 64 {
+23 -10
View File
@@ -7,8 +7,8 @@ import (
"testing"
"time"
"git.118im.com/im_team/im_workbench/internal/config"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/config"
"git.118im.com/im_team/olons_server/internal/store"
)
type fakeRepository struct {
@@ -60,7 +60,7 @@ func (f *fakeRepository) CreateRecharge(_ context.Context, input store.RechargeC
f.createdRecharge = input
return &store.RechargeOrder{ID: 8, RechargeNo: input.RechargeNo, ChannelID: input.ChannelID,
ChannelName: "人工审核", RequestAmount: input.RequestAmount, Currency: input.Currency,
Status: 1, Remark: input.Remark}, nil
Status: 2, Remark: input.Remark}, nil
}
func (f *fakeRepository) RechargeOrders(context.Context, int64, int64, int, int, int) (*store.RechargePage, error) {
return &store.RechargePage{Items: f.rechargeOrders, Total: int64(len(f.rechargeOrders))}, nil
@@ -101,14 +101,14 @@ func TestQuoteFailsClosed(t *testing.T) {
}
func TestNormalizePaymentAccount(t *testing.T) {
input := PaymentAccountInput{AccountType: "bank_card", BankCode: "icbc", BankName: "中国工商银行",
HolderName: "张三", AccountNo: "6222 0000-0000 1234", ReservedPhone: "13800138000"}
accountType, err := normalizePaymentAccount(&input)
if err != nil {
input := PaymentAccountInput{BankName: "中国工商银行", HolderName: "张三",
IdentityNo: "11010119900101123X", AccountNo: "6222 0000-0000 1234",
WechatQRAttachmentID: 8, AlipayQRAttachmentID: 9}
if err := normalizePaymentAccount(&input); err != nil {
t.Fatal(err)
}
if accountType != 1 || input.BankCode != "ICBC" || input.AccountNo != "6222000000001234" ||
maskAccount(input.AccountNo) != "6222********1234" {
if input.IdentityNo != "11010119900101123X" || input.AccountNo != "6222000000001234" ||
maskAccount(input.AccountNo) != "6222********1234" || maskIdentityNo(input.IdentityNo) != "1101**********123X" {
t.Fatalf("unexpected normalized input: %#v", input)
}
}
@@ -138,7 +138,7 @@ func TestCreateRechargeValidatesChannelAndMapsOrder(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if result.Status != "pending_payment" || result.RequestAmount != 50000 ||
if result.Status != "reviewing" || result.RequestAmount != 50000 ||
repository.createdRecharge.MemberID != 9 || repository.createdRecharge.Currency != "CNY" {
t.Fatalf("unexpected recharge: %#v, creation: %#v", result, repository.createdRecharge)
}
@@ -219,3 +219,16 @@ func TestEncryptionRoundTrip(t *testing.T) {
t.Fatalf("unexpected round trip: %q, %v", plain, err)
}
}
func TestMasterKeyDerivesIndependentFinanceKeys(t *testing.T) {
service := New(&fakeRepository{}, nil, config.AuthConfig{
FinanceMasterKey: "production-master-key-with-at-least-32-characters",
})
if service.aead == nil || len(service.lookupKey) != 32 {
t.Fatal("expected the finance keys to be derived from the master key")
}
if string(deriveKey("production-master-key-with-at-least-32-characters", "data-encryption")) ==
string(deriveKey("production-master-key-with-at-least-32-characters", "lookup-hmac")) {
t.Fatal("derived keys must be isolated by purpose")
}
}
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"net/http"
"strings"
accountsvc "git.118im.com/im_team/im_workbench/internal/accounts"
"git.118im.com/im_team/im_workbench/internal/auth"
"git.118im.com/im_team/im_workbench/internal/store"
accountsvc "git.118im.com/im_team/olons_server/internal/accounts"
"git.118im.com/im_team/olons_server/internal/auth"
"git.118im.com/im_team/olons_server/internal/store"
)
type accountRegisterRequest struct {
+14 -9
View File
@@ -3,12 +3,13 @@ package httpapi
import (
"net/http"
"git.118im.com/im_team/im_workbench/internal/accounts"
"git.118im.com/im_team/im_workbench/internal/auth"
"git.118im.com/im_team/im_workbench/internal/finance"
"git.118im.com/im_team/im_workbench/internal/mall"
"git.118im.com/im_team/im_workbench/internal/portal"
"git.118im.com/im_team/im_workbench/internal/tasks"
"git.118im.com/im_team/olons_server/internal/accounts"
"git.118im.com/im_team/olons_server/internal/attachments"
"git.118im.com/im_team/olons_server/internal/auth"
"git.118im.com/im_team/olons_server/internal/finance"
"git.118im.com/im_team/olons_server/internal/mall"
"git.118im.com/im_team/olons_server/internal/portal"
"git.118im.com/im_team/olons_server/internal/tasks"
)
type API struct {
@@ -18,10 +19,11 @@ type API struct {
tasks *tasks.Service
mall *mall.Service
finance *finance.Service
attachments *attachments.Service
}
func New(authenticator *auth.Authenticator, accountService *accounts.Service, portalService *portal.Service, taskService *tasks.Service, mallService *mall.Service, financeService *finance.Service) *API {
return &API{auth: authenticator, accounts: accountService, portal: portalService, tasks: taskService, mall: mallService, finance: financeService}
func New(authenticator *auth.Authenticator, accountService *accounts.Service, portalService *portal.Service, taskService *tasks.Service, mallService *mall.Service, financeService *finance.Service, attachmentService *attachments.Service) *API {
return &API{auth: authenticator, accounts: accountService, portal: portalService, tasks: taskService, mall: mallService, finance: financeService, attachments: attachmentService}
}
func (a *API) Register(mux *http.ServeMux) {
@@ -39,6 +41,8 @@ func (a *API) Register(mux *http.ServeMux) {
mux.HandleFunc("/v1/leaderboard", a.leaderboard)
mux.HandleFunc("/v1/customer-services", a.customerServices)
mux.HandleFunc("/v1/notices", a.notices)
mux.HandleFunc("/v1/notices/popup", a.popupNotice)
mux.HandleFunc("/v1/notices/", a.noticeResource)
mux.HandleFunc("/v1/task-halls", a.taskHalls)
mux.HandleFunc("/v1/tasks/eligibility", a.taskEligibility)
mux.HandleFunc("/v1/tasks/stats", a.taskStats)
@@ -48,6 +52,7 @@ func (a *API) Register(mux *http.ServeMux) {
mux.HandleFunc("/v1/manual-products", a.manualProducts)
mux.HandleFunc("/v1/manual-products/", a.manualProduct)
mux.HandleFunc("/v1/payment-accounts", a.paymentAccounts)
mux.HandleFunc("/v1/attachments/images", a.uploadImage)
mux.HandleFunc("/v1/wallet/ledgers", a.walletLedgers)
mux.HandleFunc("/v1/recharge/channels", a.rechargeChannels)
mux.HandleFunc("/v1/recharge-orders", a.rechargeOrders)
@@ -58,5 +63,5 @@ func (a *API) Register(mux *http.ServeMux) {
}
func (a *API) Healthz(w http.ResponseWriter, r *http.Request) {
writeSuccess(w, r, map[string]any{"status": "ok", "service": "workbench"})
writeSuccess(w, r, map[string]any{"status": "ok", "service": "olons_server"})
}
+36
View File
@@ -0,0 +1,36 @@
package httpapi
import (
"errors"
"net/http"
"git.118im.com/im_team/olons_server/internal/attachments"
)
func (a *API) uploadImage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
methodNotAllowed(w, r, http.MethodPost)
return
}
principal, ok := a.authenticateMember(w, r)
if !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, 6<<20)
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, r, http.StatusBadRequest, 30000, "IMAGE_INVALID", "validation failed", map[string]any{"field": "file"})
return
}
defer file.Close()
result, err := a.attachments.UploadImage(r.Context(), principal.MemberID, header.Filename, file)
if err != nil {
if errors.Is(err, attachments.ErrImageInvalid) {
writeError(w, r, http.StatusBadRequest, 30000, "IMAGE_INVALID", "validation failed", map[string]any{"field": "file"})
} else {
writeError(w, r, http.StatusInternalServerError, 90000, reasonInternal, "internal error", nil)
}
return
}
writeSuccess(w, r, result)
}
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"strconv"
"strings"
"git.118im.com/im_team/im_workbench/internal/auth"
"git.118im.com/im_team/im_workbench/internal/finance"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/auth"
"git.118im.com/im_team/olons_server/internal/finance"
"git.118im.com/im_team/olons_server/internal/store"
)
func (a *API) paymentAccounts(w http.ResponseWriter, r *http.Request) {
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strconv"
"strings"
"git.118im.com/im_team/im_workbench/internal/mall"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/mall"
"git.118im.com/im_team/olons_server/internal/store"
)
func (a *API) manualProducts(w http.ResponseWriter, r *http.Request) {
+53 -2
View File
@@ -3,10 +3,11 @@ package httpapi
import (
"errors"
"net/http"
"strconv"
"strings"
"git.118im.com/im_team/im_workbench/internal/portal"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/portal"
"git.118im.com/im_team/olons_server/internal/store"
)
var publicContentKeys = map[string]struct{}{
@@ -187,6 +188,56 @@ func (a *API) notices(w http.ResponseWriter, r *http.Request) {
writeSuccess(w, r, result)
}
func (a *API) popupNotice(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
methodNotAllowed(w, r, http.MethodGet)
return
}
if !a.ensurePortal(w, r) {
return
}
principal, ok := a.authenticateMember(w, r)
if !ok {
return
}
result, err := a.portal.PopupNotice(r.Context(), principal.TenantID, principal.MemberID)
if err != nil {
writePortalError(w, r, err)
return
}
writeSuccess(w, r, result)
}
func (a *API) noticeResource(w http.ResponseWriter, r *http.Request) {
path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/notices/"), "/")
parts := strings.Split(path, "/")
if len(parts) != 2 || parts[1] != "read" {
writePortalError(w, r, store.ErrResourceNotFound)
return
}
noticeID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || noticeID <= 0 {
writePortalError(w, r, store.ErrResourceNotFound)
return
}
if r.Method != http.MethodPost {
methodNotAllowed(w, r, http.MethodPost)
return
}
if !a.ensurePortal(w, r) {
return
}
principal, ok := a.authenticateMember(w, r)
if !ok {
return
}
if err := a.portal.MarkNoticeRead(r.Context(), principal.TenantID, principal.MemberID, noticeID); err != nil {
writePortalError(w, r, err)
return
}
writeSuccess(w, r, struct{}{})
}
func (a *API) ensurePortal(w http.ResponseWriter, r *http.Request) bool {
if a.portal != nil {
return true
+3 -3
View File
@@ -4,9 +4,9 @@ import (
"errors"
"net/http"
accountsvc "git.118im.com/im_team/im_workbench/internal/accounts"
"git.118im.com/im_team/im_workbench/internal/auth"
"git.118im.com/im_team/im_workbench/internal/store"
accountsvc "git.118im.com/im_team/olons_server/internal/accounts"
"git.118im.com/im_team/olons_server/internal/auth"
"git.118im.com/im_team/olons_server/internal/store"
)
type avatarRequest struct {
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"strconv"
"strings"
"git.118im.com/im_team/im_workbench/internal/auth"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/im_workbench/internal/tasks"
"git.118im.com/im_team/olons_server/internal/auth"
"git.118im.com/im_team/olons_server/internal/store"
"git.118im.com/im_team/olons_server/internal/tasks"
)
type taskClaimRequest struct {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"strings"
"unicode/utf8"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/store"
)
const (
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"errors"
"testing"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/store"
)
type fakeRepository struct {
+18 -2
View File
@@ -7,8 +7,8 @@ import (
"regexp"
"strings"
"git.118im.com/im_team/im_workbench/internal/accounts"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/accounts"
"git.118im.com/im_team/olons_server/internal/store"
)
var (
@@ -22,6 +22,8 @@ type Repository interface {
PublishedContent(context.Context, int64, string) (*store.Content, error)
ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error)
ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error)
LatestPopupNotice(context.Context, int64, int64) (*store.MemberNotice, error)
MarkMemberNoticeRead(context.Context, int64, int64, int64) error
MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error)
MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error)
TaskLeaderboard(context.Context, int64, int) ([]store.LeaderboardMember, error)
@@ -203,6 +205,20 @@ func (s *Service) Notices(ctx context.Context, tenantID, memberID int64, page, p
return &NoticePage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil
}
func (s *Service) PopupNotice(ctx context.Context, tenantID, memberID int64) (*Notice, error) {
row, err := s.repository.LatestPopupNotice(ctx, tenantID, memberID)
if err != nil || row == nil {
return nil, err
}
return &Notice{ID: row.ID, Title: row.Title, Content: row.Content,
SourceType: noticeSourceName(row.SourceType), StartAt: row.StartAt, EndAt: row.EndAt,
CreatedAt: row.CreatedAt}, nil
}
func (s *Service) MarkNoticeRead(ctx context.Context, tenantID, memberID, noticeID int64) error {
return s.repository.MarkMemberNoticeRead(ctx, tenantID, memberID, noticeID)
}
func (s *Service) Content(ctx context.Context, tenantID int64, key string) (*Content, error) {
key = strings.ToLower(strings.TrimSpace(key))
if !contentKeyPattern.MatchString(key) {
+24 -2
View File
@@ -8,8 +8,8 @@ import (
"strings"
"testing"
"git.118im.com/im_team/im_workbench/internal/accounts"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/accounts"
"git.118im.com/im_team/olons_server/internal/store"
)
type fakeRepository struct {
@@ -42,6 +42,15 @@ func (r fakeRepository) ActiveMemberLevels(context.Context, int64) ([]store.Memb
func (r fakeRepository) ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error) {
return r.notices, nil
}
func (r fakeRepository) LatestPopupNotice(context.Context, int64, int64) (*store.MemberNotice, error) {
if len(r.notices) == 0 {
return nil, nil
}
return &r.notices[0], nil
}
func (r fakeRepository) MarkMemberNoticeRead(context.Context, int64, int64, int64) error {
return nil
}
func (r fakeRepository) MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error) {
if r.noticePage == nil {
return &store.MemberNoticePage{Items: []store.MemberNotice{}}, nil
@@ -193,3 +202,16 @@ func TestNoticesRejectsOversizedPage(t *testing.T) {
t.Fatalf("error = %v, want %v", err, ErrPageSizeInvalid)
}
}
func TestPopupNoticeMapsLatestUnreadNotice(t *testing.T) {
service := New(fakeRepository{notices: []store.MemberNotice{{
ID: 9, Title: "公告通知", Content: "新活动已上线", SourceType: 1,
}}})
notice, err := service.PopupNotice(context.Background(), 1, 7)
if err != nil {
t.Fatal(err)
}
if notice == nil || notice.ID != 9 || notice.Content != "新活动已上线" || notice.SourceType != "real" {
t.Fatalf("unexpected popup notice: %+v", notice)
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ func (s *Store) RegisterAccount(ctx context.Context, input AccountRegistration)
(tenant_id,member_no,username,mobile,password_hash,
level_id,parent_id,level,tree,invite_code,member_status,risk_status,auto_dispatch,
credit_score,is_test_account,created_by,updated_by)
VALUES(?,?,?,?,?,?,?,?,?,?,1,1,2,100,2,0,0)`,
VALUES(?,?,?,?,?,?,?,?,?,?,1,1,1,100,2,0,0)`,
input.TenantID, input.MemberNo, input.Username, input.Mobile, input.PasswordHash,
levelID, parentID, teamLevel, tree, input.InviteCode)
if err != nil {
+34
View File
@@ -0,0 +1,34 @@
package store
import "context"
type AttachmentCreation struct {
MemberID int64
Name string
MimeType string
Path string
URL string
Size int64
Ext string
MD5 string
}
type Attachment struct {
ID int64 `json:"id"`
URL string `json:"url"`
}
func (s *Store) CreateAttachment(ctx context.Context, input AttachmentCreation) (*Attachment, error) {
result, err := s.db.ExecContext(ctx, `INSERT INTO hg_sys_attachment
(app_id,member_id,cate_id,drive,name,kind,mime_type,naive_type,path,file_url,size,ext,md5,status,created_at,updated_at)
VALUES('olons_server',?,0,'local',?,'image',?,'image',?,?,?, ?,?,1,UTC_TIMESTAMP(),UTC_TIMESTAMP())`,
input.MemberID, input.Name, input.MimeType, input.Path, input.URL, input.Size, input.Ext, input.MD5)
if err != nil {
return nil, err
}
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
return &Attachment{ID: id, URL: input.URL}, nil
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"time"
"git.118im.com/im_team/im_workbench/internal/config"
"git.118im.com/im_team/olons_server/internal/config"
"github.com/jmoiron/sqlx"
)
+43
View File
@@ -161,6 +161,49 @@ func (s *Store) ActiveMemberNotices(ctx context.Context, tenantID, memberID int6
return rows, err
}
func (s *Store) LatestPopupNotice(ctx context.Context, tenantID, memberID int64) (*MemberNotice, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
}
var row MemberNotice
err := s.db.GetContext(ctx, &row, `SELECT id,title,content,source_type,
DATE_FORMAT(start_at,'%Y-%m-%dT%H:%i:%sZ') AS start_at,
DATE_FORMAT(end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_at,
DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at
FROM hg_app_member_notice
WHERE tenant_id=? AND member_id IN (0,?) AND notice_status=1
AND start_at<=UTC_TIMESTAMP(3) AND end_at>UTC_TIMESTAMP(3)
ORDER BY start_at DESC,id DESC LIMIT 1`, tenantID, memberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return &row, err
}
func (s *Store) MarkMemberNoticeRead(ctx context.Context, tenantID, memberID, noticeID int64) error {
if s == nil || s.db == nil {
return errors.New("store is unavailable")
}
result, err := s.db.ExecContext(ctx, `INSERT INTO hg_app_member_notice_read
(tenant_id,notice_id,member_id,read_at,created_at)
SELECT n.tenant_id,n.id,?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3)
FROM hg_app_member_notice n
WHERE n.tenant_id=? AND n.id=? AND n.member_id IN (0,?) AND n.notice_status=1
AND n.start_at<=UTC_TIMESTAMP(3) AND n.end_at>UTC_TIMESTAMP(3)
ON DUPLICATE KEY UPDATE read_at=VALUES(read_at)`, memberID, tenantID, noticeID, memberID)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return ErrResourceNotFound
}
return nil
}
func (s *Store) MemberNotices(ctx context.Context, tenantID, memberID int64, offset, limit int) (*MemberNoticePage, error) {
if s == nil || s.db == nil {
return nil, errors.New("store is unavailable")
+1 -1
View File
@@ -92,7 +92,7 @@ func (s *Store) UpdateAvatar(ctx context.Context, tenantID, memberID, attachment
var avatar Avatar
err = tx.GetContext(ctx, &avatar, `SELECT id AS attachment_id,COALESCE(file_url,'') AS url FROM hg_sys_attachment
WHERE id=? AND app_id='workbench' AND member_id=? AND status=1
WHERE id=? AND app_id='olons_server' AND member_id=? AND status=1
AND mime_type LIKE 'image/%' LIMIT 1 FOR UPDATE`, attachmentID, memberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAttachmentInvalid
+6 -4
View File
@@ -56,6 +56,8 @@ type RechargePage struct {
Total int64
}
const rechargeStatusReview = 2
func (s *Store) RechargeChannels(ctx context.Context, tenantID int64) ([]RechargeChannel, error) {
items := make([]RechargeChannel, 0)
err := s.db.SelectContext(ctx, &items, `SELECT id,channel_code,name,channel_type,min_amount,max_amount,
@@ -95,7 +97,7 @@ func (s *Store) CreateRecharge(ctx context.Context, input RechargeCreation) (*Re
for _, attachmentID := range input.VoucherAttachmentIDs {
var count int
if err = tx.GetContext(ctx, &count, `SELECT COUNT(*) FROM hg_sys_attachment WHERE tenant_id=? AND id=?
AND app_id='workbench' AND member_id=? AND status=1`, input.TenantID, attachmentID, input.MemberID); err != nil {
AND app_id='olons_server' AND member_id=? AND status=1`, input.TenantID, attachmentID, input.MemberID); err != nil {
return nil, err
}
if count != 1 {
@@ -109,8 +111,8 @@ func (s *Store) CreateRecharge(ctx context.Context, input RechargeCreation) (*Re
result, err := tx.ExecContext(ctx, `INSERT INTO hg_recharge_order
(tenant_id,recharge_no,member_id,channel_id,request_amount,paid_amount,currency,
voucher_attachment_ids,recharge_type,recharge_status,remark,created_by,updated_by)
VALUES(?,?,?,?,?,0,?,?,1,1,?,?,?)`, input.TenantID, input.RechargeNo, input.MemberID,
input.ChannelID, input.RequestAmount, input.Currency, vouchers, input.Remark, input.MemberID, input.MemberID)
VALUES(?,?,?,?,?,0,?,?,1,?,?,?,?)`, input.TenantID, input.RechargeNo, input.MemberID,
input.ChannelID, input.RequestAmount, input.Currency, vouchers, rechargeStatusReview, input.Remark, input.MemberID, input.MemberID)
if err != nil {
return nil, err
}
@@ -123,7 +125,7 @@ func (s *Store) CreateRecharge(ctx context.Context, input RechargeCreation) (*Re
}
return &RechargeOrder{ID: id, RechargeNo: input.RechargeNo, ChannelID: channel.ID,
ChannelName: channel.Name, RequestAmount: input.RequestAmount, Currency: input.Currency,
VoucherAttachmentIDs: vouchers, Status: 1, Remark: input.Remark}, nil
VoucherAttachmentIDs: vouchers, Status: rechargeStatusReview, Remark: input.Remark}, nil
}
func (s *Store) RechargeOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*RechargePage, error) {
+264 -44
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jmoiron/sqlx"
@@ -110,6 +111,9 @@ type TaskSubmissionInput struct {
SubmissionNo string
EvidenceText string
AttachmentIDs []int64
LedgerNo string
IdempotencyKey string
SettledAt time.Time
}
type TaskSubmissionResult struct {
@@ -160,8 +164,9 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
var member struct {
LevelID int64 `db:"level_id"`
AutoDispatch int `db:"auto_dispatch"`
}
err = tx.GetContext(ctx, &member, `SELECT level_id FROM hg_app_member
err = tx.GetContext(ctx, &member, `SELECT level_id,auto_dispatch FROM hg_app_member
WHERE tenant_id=? AND id=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAccountNotFound
@@ -227,32 +232,14 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
return nil, ErrDailyLimitReached
}
}
var task struct {
ID int64 `db:"id"`
Title string `db:"title"`
CoverURL string `db:"cover_url"`
CommissionAmount int64 `db:"commission_amount"`
CommissionRate int `db:"commission_rate"`
PerMemberLimit int `db:"per_member_limit"`
}
err = tx.GetContext(ctx, &task, `SELECT t.id,t.title,t.cover_url,t.commission_amount,t.commission_rate,t.per_member_limit
FROM hg_task t JOIN hg_task_level tl ON tl.tenant_id=t.tenant_id AND tl.task_id=t.id AND tl.level_id=?
WHERE t.tenant_id=? AND t.hall_id=? AND t.publish_status=3 AND t.deleted_at IS NULL
AND (t.start_at IS NULL OR t.start_at<=UTC_TIMESTAMP(3)) AND (t.end_at IS NULL OR t.end_at>UTC_TIMESTAMP(3))
AND (t.daily_limit=0 OR (SELECT COUNT(*) FROM hg_trade_order od WHERE od.tenant_id=t.tenant_id AND od.task_id=t.id
AND od.order_type=2 AND od.created_at>=UTC_DATE() AND od.created_at<UTC_DATE()+INTERVAL 1 DAY)<t.daily_limit)
AND (t.per_member_limit=0 OR (SELECT COUNT(*) FROM hg_trade_order om WHERE om.tenant_id=t.tenant_id
AND om.task_id=t.id AND om.member_id=? AND om.order_type=2 AND om.created_at>=UTC_DATE() AND om.created_at<UTC_DATE()+INTERVAL 1 DAY)<t.per_member_limit)
ORDER BY t.sort,t.id LIMIT 1 FOR UPDATE`, member.LevelID, input.TenantID, input.HallID, input.MemberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNoTaskAvailable
}
if err != nil {
return nil, err
dispatchMode, message := "manual", "等待后台匹配订单"
if member.AutoDispatch == 1 {
dispatchMode, message = "auto", "正在自动匹配订单"
}
rules, err := json.Marshal(map[string]any{
"commission_amount": task.CommissionAmount,
"commission_rate": task.CommissionRate,
"source": "member_auto_claim",
"dispatch_mode": dispatchMode,
"hall_id": input.HallID,
"deadline_at": input.DeadlineAt.UTC().Format(time.RFC3339),
"member_level_id": member.LevelID,
})
@@ -262,10 +249,9 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
result, err := tx.ExecContext(ctx, `INSERT INTO hg_trade_order
(tenant_id,order_no,member_id,task_id,product_id,product_name,order_type,quantity,
unit_price_amount,total_amount,commission_amount,currency,member_level_id,rule_snapshot,
message,order_status,freeze_status,matched_at,created_by,updated_by)
VALUES(?,?,?,?,0,?,2,1,0,0,?,'CNY',?,?,?,2,1,UTC_TIMESTAMP(3),0,0)`,
input.TenantID, input.OrderNo, input.MemberID, task.ID, task.Title, task.CommissionAmount,
member.LevelID, rules, "任务匹配成功,请在截止时间前完成")
message,order_status,freeze_status,created_by,updated_by)
VALUES(?,?,?,0,0,'',2,0,0,0,0,'CNY',?,?,?,1,1,0,0)`,
input.TenantID, input.OrderNo, input.MemberID, member.LevelID, rules, message)
if err != nil {
if isDuplicate(err) {
return nil, ErrPendingOrderExists
@@ -279,11 +265,148 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
if err := tx.Commit(); err != nil {
return nil, err
}
return &TaskOrder{ID: orderID, OrderNo: input.OrderNo, TaskID: task.ID, ProductName: task.Title,
CoverURL: task.CoverURL, Quantity: 1, CommissionAmount: task.CommissionAmount, Currency: "CNY",
MemberLevelID: member.LevelID, RuleSnapshot: rules, Message: "任务匹配成功,请在截止时间前完成",
OrderStatus: orderStatusPending, FreezeStatus: 1, DeadlineAt: input.DeadlineAt.UTC().Format(time.RFC3339),
MatchedAt: time.Now().UTC().Format(time.RFC3339), CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil
return &TaskOrder{ID: orderID, OrderNo: input.OrderNo, Currency: "CNY",
MemberLevelID: member.LevelID, RuleSnapshot: rules, Message: message,
OrderStatus: orderStatusMatching, FreezeStatus: 1, DeadlineAt: input.DeadlineAt.UTC().Format(time.RFC3339),
CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil
}
func (s *Store) AutoMatchTaskOrder(ctx context.Context, tenantID, memberID, orderID int64, delaySeconds int, minAmount, maxAmount int64) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var order struct {
OrderNo string `db:"order_no"`
Status int `db:"order_status"`
MemberLevelID int64 `db:"member_level_id"`
RuleSnapshot json.RawMessage `db:"rule_snapshot"`
AutoDispatch int `db:"auto_dispatch"`
CreatedAt time.Time `db:"created_at"`
}
err = tx.GetContext(ctx, &order, `SELECT o.order_no,o.order_status,o.member_level_id,o.rule_snapshot,o.created_at,m.auto_dispatch
FROM hg_trade_order o JOIN hg_app_member m ON m.tenant_id=o.tenant_id AND m.id=o.member_id
WHERE o.tenant_id=? AND o.member_id=? AND o.id=? AND o.order_type=2 LIMIT 1 FOR UPDATE`, tenantID, memberID, orderID)
if errors.Is(err, sql.ErrNoRows) {
return ErrOrderNotFound
}
if err != nil {
return err
}
if order.Status != orderStatusMatching {
return tx.Commit()
}
if order.AutoDispatch != 1 || delaySeconds > 0 && time.Now().UTC().Before(order.CreatedAt.UTC().Add(time.Duration(delaySeconds)*time.Second)) {
return tx.Commit()
}
var account struct {
ID int64 `db:"id"`
AvailableAmount int64 `db:"available_amount"`
FrozenAmount int64 `db:"frozen_amount"`
Version int `db:"version"`
}
err = tx.GetContext(ctx, &account, `SELECT id,available_amount,frozen_amount,version FROM hg_wallet_account
WHERE tenant_id=? AND member_id=? AND currency='CNY' AND account_status=1 LIMIT 1 FOR UPDATE`, tenantID, memberID)
if errors.Is(err, sql.ErrNoRows) {
return ErrAccountRestricted
}
if err != nil {
return err
}
lowerAmount := max(int64(1), minAmount)
upperAmount := account.AvailableAmount
if maxAmount > 0 && maxAmount < upperAmount {
upperAmount = maxAmount
}
if lowerAmount > upperAmount {
return ErrNoTaskAvailable
}
var product struct {
ID int64 `db:"id"`
ProductNo string `db:"product_no"`
Name string `db:"name"`
PriceAmount int64 `db:"price_amount"`
Currency string `db:"currency"`
}
err = tx.GetContext(ctx, &product, `SELECT id,product_no,name,price_amount,currency FROM hg_product
WHERE tenant_id=? AND publish_status=3 AND deleted_at IS NULL AND currency='CNY' AND price_amount>=? AND price_amount<=?
AND (start_at IS NULL OR start_at<=UTC_TIMESTAMP(3)) AND (end_at IS NULL OR end_at>UTC_TIMESTAMP(3))
ORDER BY RAND() LIMIT 1 FOR UPDATE`, tenantID, lowerAmount, upperAmount)
if errors.Is(err, sql.ErrNoRows) {
return ErrNoTaskAvailable
}
if err != nil {
return err
}
var levelRule struct {
VersionID int64 `db:"version_id"`
CommissionRate int `db:"commission_rate"`
}
err = tx.GetContext(ctx, &levelRule, `SELECT v.id AS version_id,v.commission_rate
FROM hg_app_member_level l JOIN hg_app_member_level_version v
ON v.tenant_id=l.tenant_id AND v.id=l.current_version_id AND v.publish_status=3
WHERE l.tenant_id=? AND l.id=? AND l.status=1 AND l.deleted_at IS NULL LIMIT 1`, tenantID, order.MemberLevelID)
if errors.Is(err, sql.ErrNoRows) {
return ErrLevelNotEligible
}
if err != nil {
return err
}
snapshot := map[string]any{}
if len(order.RuleSnapshot) > 0 {
_ = json.Unmarshal(order.RuleSnapshot, &snapshot)
}
snapshot["match_source"] = "system_auto_match"
snapshot["product_id"] = product.ID
snapshot["product_no"] = product.ProductNo
snapshot["unit_price_amount"] = product.PriceAmount
snapshot["quantity"] = 1
snapshot["level_version_id"] = levelRule.VersionID
snapshot["commission_rate"] = levelRule.CommissionRate
rules, err := json.Marshal(snapshot)
if err != nil {
return err
}
commissionAmount := product.PriceAmount * int64(levelRule.CommissionRate) / 10000
afterAvailable := account.AvailableAmount - product.PriceAmount
afterFrozen := account.FrozenAmount + product.PriceAmount
walletResult, err := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,version=version+1,
updated_at=UTC_TIMESTAMP(3) WHERE tenant_id=? AND id=? AND version=? AND available_amount=? AND frozen_amount=?`,
afterAvailable, afterFrozen, tenantID, account.ID, account.Version, account.AvailableAmount, account.FrozenAmount)
if err != nil {
return err
}
if affected, _ := walletResult.RowsAffected(); affected != 1 {
return ErrConcurrentUpdate
}
if _, err = insertTaskLedger(ctx, tx, tenantID, memberID, account.ID, orderID, fmt.Sprintf("WL%dMA", orderID),
"task_principal_hold", order.OrderNo, "available", 2, product.PriceAmount, account.AvailableAmount, afterAvailable,
"match:available", "自动派单扣除可用余额"); err != nil {
return err
}
if _, err = insertTaskLedger(ctx, tx, tenantID, memberID, account.ID, orderID, fmt.Sprintf("WL%dMF", orderID),
"task_principal_hold", order.OrderNo, "frozen", 1, product.PriceAmount, account.FrozenAmount, afterFrozen,
"match:frozen", "自动派单冻结订单本金"); err != nil {
return err
}
result, err := tx.ExecContext(ctx, `UPDATE hg_trade_order SET product_id=?,product_name=?,quantity=1,
unit_price_amount=?,total_amount=?,commission_amount=?,currency=?,rule_snapshot=?,message=?,
order_status=2,freeze_status=1,matched_at=UTC_TIMESTAMP(3),updated_at=UTC_TIMESTAMP(3)
WHERE tenant_id=? AND member_id=? AND id=? AND order_type=2 AND order_status=1`, product.ID, product.Name,
product.PriceAmount, product.PriceAmount, commissionAmount, product.Currency, rules,
"系统已自动匹配订单,订单本金已扣除,请在截止时间前完成", tenantID, memberID, orderID)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected != 1 {
return ErrConcurrentUpdate
}
return tx.Commit()
}
func (s *Store) TaskOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*TaskOrderPage, error) {
@@ -364,13 +487,15 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
}
defer tx.Rollback()
var order struct {
OrderNo string `db:"order_no"`
TaskID int64 `db:"task_id"`
MemberLevelID int64 `db:"member_level_id"`
TotalAmount int64 `db:"total_amount"`
CommissionAmount int64 `db:"commission_amount"`
OrderStatus int `db:"order_status"`
DeadlineAt string `db:"deadline_at"`
}
err = tx.GetContext(ctx, &order, `SELECT task_id,member_level_id,commission_amount,order_status,
err = tx.GetContext(ctx, &order, `SELECT order_no,task_id,member_level_id,total_amount,commission_amount,order_status,
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(rule_snapshot,'$.deadline_at')),'') AS deadline_at
FROM hg_trade_order WHERE tenant_id=? AND member_id=? AND order_type=2 AND id=? LIMIT 1 FOR UPDATE`,
input.TenantID, input.MemberID, input.OrderID)
@@ -383,8 +508,48 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
if order.OrderStatus != orderStatusPending {
return nil, ErrOrderState
}
var account struct {
ID int64 `db:"id"`
AvailableAmount int64 `db:"available_amount"`
FrozenAmount int64 `db:"frozen_amount"`
Status int `db:"account_status"`
Version int `db:"version"`
}
err = tx.GetContext(ctx, &account, `SELECT id,available_amount,frozen_amount,account_status,version FROM hg_wallet_account
WHERE tenant_id=? AND member_id=? AND currency='CNY' LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAccountNotFound
}
if err != nil {
return nil, err
}
if account.Status != 1 || account.FrozenAmount < order.TotalAmount {
return nil, ErrAccountRestricted
}
if order.DeadlineAt != "" {
if deadline, parseErr := time.Parse(time.RFC3339, order.DeadlineAt); parseErr != nil || !time.Now().UTC().Before(deadline) {
now := time.Now().UTC()
afterAvailable := account.AvailableAmount + order.TotalAmount
afterFrozen := account.FrozenAmount - order.TotalAmount
walletResult, updateErr := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,
version=version+1,updated_at=? WHERE tenant_id=? AND id=? AND version=?`, afterAvailable, afterFrozen,
now, input.TenantID, account.ID, account.Version)
if updateErr != nil {
return nil, updateErr
}
if affected, _ := walletResult.RowsAffected(); affected != 1 {
return nil, ErrConcurrentUpdate
}
if _, updateErr = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
fmt.Sprintf("WL%dEF", input.OrderID), "task_principal_release", order.OrderNo, "frozen", 2,
order.TotalAmount, account.FrozenAmount, afterFrozen, "expire:frozen", "订单超时释放冻结本金"); updateErr != nil {
return nil, updateErr
}
if _, updateErr = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
fmt.Sprintf("WL%dEA", input.OrderID), "task_principal_release", order.OrderNo, "available", 1,
order.TotalAmount, account.AvailableAmount, afterAvailable, "expire:available", "订单超时退回订单本金"); updateErr != nil {
return nil, updateErr
}
if _, updateErr := tx.ExecContext(ctx, `UPDATE hg_trade_order SET order_status=5,message='任务已超时',updated_at=UTC_TIMESTAMP(3)
WHERE tenant_id=? AND id=? AND order_status=2`, input.TenantID, input.OrderID); updateErr != nil {
return nil, updateErr
@@ -398,24 +563,64 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
if err := validateEvidenceAttachments(ctx, tx, input.TenantID, input.MemberID, input.AttachmentIDs); err != nil {
return nil, err
}
if input.SubmissionNo == "" {
return nil, ErrOrderState
}
attachments, err := json.Marshal(input.AttachmentIDs)
if err != nil {
return nil, err
}
now := time.Now().UTC()
_, err = tx.ExecContext(ctx, `INSERT INTO hg_task_submission
now := input.SettledAt.UTC()
if now.IsZero() {
now = time.Now().UTC()
}
principalAfter := account.AvailableAmount + order.TotalAmount
afterAmount := principalAfter + order.CommissionAmount
afterFrozen := account.FrozenAmount - order.TotalAmount
accountResult, err := tx.ExecContext(ctx, `UPDATE hg_wallet_account SET available_amount=?,frozen_amount=?,total_income=total_income+?,
version=version+1,updated_at=? WHERE tenant_id=? AND id=? AND version=?`, afterAmount, afterFrozen,
order.CommissionAmount, now, input.TenantID, account.ID, account.Version)
if err != nil {
return nil, err
}
if affected, _ := accountResult.RowsAffected(); affected != 1 {
return nil, ErrOrderState
}
if _, err = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
fmt.Sprintf("WL%dSF", input.OrderID), "task_principal_release", order.OrderNo, "frozen", 2,
order.TotalAmount, account.FrozenAmount, afterFrozen, "settle:frozen", "任务完成释放冻结本金"); err != nil {
return nil, err
}
if _, err = insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
fmt.Sprintf("WL%dSA", input.OrderID), "task_principal_return", order.OrderNo, "available", 1,
order.TotalAmount, account.AvailableAmount, principalAfter, "settle:available", "任务完成返回订单本金"); err != nil {
return nil, err
}
ledgerID, err := insertTaskLedger(ctx, tx, input.TenantID, input.MemberID, account.ID, input.OrderID,
fmt.Sprintf("WL%dSC", input.OrderID), "commission", input.SubmissionNo, "available", 1,
order.CommissionAmount, principalAfter, afterAmount, "settle:commission", "任务完成自动返佣")
if err != nil {
return nil, err
}
submissionResult, err := tx.ExecContext(ctx, `INSERT INTO hg_task_submission
(tenant_id,submission_no,trade_order_id,task_id,member_id,member_level_id,evidence_text,
evidence_attachment_ids,submission_status,commission_amount,success_rate,allow_resubmit,created_by,updated_by)
VALUES(?,?,?,?,?,?,?,?,1,?,0,2,0,0)`, input.TenantID, input.SubmissionNo, input.OrderID,
order.TaskID, input.MemberID, order.MemberLevelID, input.EvidenceText, attachments, order.CommissionAmount)
evidence_attachment_ids,submission_status,commission_amount,success_rate,reviewed_by,reviewed_at,
review_reason_code,review_reason,allow_resubmit,ledger_id,settled_at,created_by,updated_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,4,?,10000,0,?,'AUTO_SETTLED','会员提交后自动结算',2,?,?,0,0,?,?)`,
input.TenantID, input.SubmissionNo, input.OrderID, order.TaskID, input.MemberID, order.MemberLevelID,
input.EvidenceText, attachments, order.CommissionAmount, now, ledgerID, now, now, now)
if err != nil {
if isDuplicate(err) {
return nil, ErrOrderState
}
return nil, err
}
result, err := tx.ExecContext(ctx, `UPDATE hg_trade_order SET order_status=3,message='已提交,等待审核',updated_at=UTC_TIMESTAMP(3)
WHERE tenant_id=? AND id=? AND member_id=? AND order_status=2`, input.TenantID, input.OrderID, input.MemberID)
if affected, _ := submissionResult.RowsAffected(); affected != 1 {
return nil, ErrOrderState
}
result, err := tx.ExecContext(ctx, `UPDATE hg_trade_order SET order_status=4,message='订单完成,佣金已入账',
completed_at=?,updated_at=?
WHERE tenant_id=? AND id=? AND member_id=? AND order_status=2`, now, now, input.TenantID, input.OrderID, input.MemberID)
if err != nil {
return nil, err
}
@@ -425,7 +630,22 @@ func (s *Store) SubmitTask(ctx context.Context, input TaskSubmissionInput) (*Tas
if err := tx.Commit(); err != nil {
return nil, err
}
return &TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: orderStatusProcessing, SubmittedAt: now}, nil
return &TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: orderStatusCompleted, SubmittedAt: now}, nil
}
func insertTaskLedger(ctx context.Context, tx *sqlx.Tx, tenantID, memberID, accountID, orderID int64,
ledgerNo, bizType, bizNo, entryType string, direction int, amount, beforeAmount, afterAmount int64,
idempotencySuffix, remark string) (int64, error) {
result, err := tx.ExecContext(ctx, `INSERT INTO hg_wallet_ledger
(tenant_id,ledger_no,account_id,member_id,biz_type,biz_no,entry_type,direction,amount,
before_amount,after_amount,currency,reversal_of,idempotency_key,remark,operator_id,occurred_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,'CNY',0,?,?,?,UTC_TIMESTAMP(3))`, tenantID, ledgerNo, accountID, memberID,
bizType, bizNo, entryType, direction, amount, beforeAmount, afterAmount,
fmt.Sprintf("task:%d:%s", orderID, idempotencySuffix), remark, memberID)
if err != nil {
return 0, err
}
return result.LastInsertId()
}
func validateEvidenceAttachments(ctx context.Context, tx *sqlx.Tx, tenantID, memberID int64, ids []int64) error {
@@ -433,7 +653,7 @@ func validateEvidenceAttachments(ctx context.Context, tx *sqlx.Tx, tenantID, mem
return nil
}
query, args, err := sqlx.In(`SELECT COUNT(*) FROM hg_sys_attachment WHERE id IN (?)
AND tenant_id=? AND app_id='workbench' AND member_id=? AND status=1
AND tenant_id=? AND app_id='olons_server' AND member_id=? AND status=1
AND (mime_type LIKE 'image/%' OR mime_type='application/pdf')`, ids, tenantID, memberID)
if err != nil {
return err
+47 -29
View File
@@ -16,12 +16,17 @@ type PaymentAccount struct {
BankName string `db:"bank_name"`
HolderCipher string `db:"holder_cipher"`
HolderMask string `db:"holder_mask"`
IdentityNoCipher string `db:"identity_no_cipher"`
IdentityNoHash string `db:"identity_no_hash"`
IdentityNoMask string `db:"identity_no_mask"`
MobileCipher string `db:"mobile_cipher"`
MobileMask string `db:"mobile_mask"`
AccountNoCipher string `db:"account_no_cipher"`
AccountNoHash string `db:"account_no_hash"`
AccountNoMask string `db:"account_no_mask"`
QRAttachmentID int64 `db:"qr_attachment_id"`
WechatQRAttachmentID int64 `db:"wechat_qr_attachment_id"`
AlipayQRAttachmentID int64 `db:"alipay_qr_attachment_id"`
VerifyStatus int `db:"verify_status"`
IsDefault int `db:"is_default"`
CreatedAt string `db:"created_at"`
@@ -31,18 +36,19 @@ type PaymentAccount struct {
type PaymentAccountCreation struct {
TenantID int64
MemberID int64
ExpectedTradePasswordHash string
AccountType int
BankCode string
BankName string
HolderCipher string
HolderMask string
MobileCipher string
MobileMask string
IdentityNoCipher string
IdentityNoHash string
IdentityNoMask string
AccountNoCipher string
AccountNoHash string
AccountNoMask string
QRAttachmentID int64
WechatQRAttachmentID int64
AlipayQRAttachmentID int64
}
type WithdrawalChannel struct {
@@ -76,10 +82,12 @@ type WithdrawalCreation struct {
ExpectedBankCode string
ExpectedBankName string
ExpectedHolderCipher string
ExpectedMobileCipher string
ExpectedIdentityNoCipher string
ExpectedIdentityNoHash string
ExpectedAccountNoCipher string
ExpectedAccountNoHash string
ExpectedQRAttachmentID int64
ExpectedWechatQRAttachmentID int64
ExpectedAlipayQRAttachmentID int64
Channel WithdrawalChannel
RequestAmount int64
FeeAmount int64
@@ -126,8 +134,10 @@ type WithdrawalPage struct {
func (s *Store) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) {
items := make([]PaymentAccount, 0)
err := s.db.SelectContext(ctx, &items, `SELECT id,account_type,bank_code,bank_name,holder_mask,mobile_mask,
account_no_mask,qr_attachment_id,verify_status,is_default,
err := s.db.SelectContext(ctx, &items, `SELECT id,account_type,bank_code,bank_name,holder_mask,
COALESCE(identity_no_mask,'') AS identity_no_mask,account_no_mask,
COALESCE(wechat_qr_attachment_id,0) AS wechat_qr_attachment_id,
COALESCE(alipay_qr_attachment_id,0) AS alipay_qr_attachment_id,verify_status,is_default,
DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND deleted_at IS NULL
@@ -143,26 +153,23 @@ func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCr
defer tx.Rollback()
var memberID int64
err = tx.GetContext(ctx, &memberID, `SELECT id FROM hg_app_member WHERE tenant_id=? AND id=?
AND trade_password_hash=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`,
input.TenantID, input.MemberID, input.ExpectedTradePasswordHash)
AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrConcurrentUpdate
}
if err != nil {
return nil, err
}
if input.QRAttachmentID > 0 {
var attachmentCount int
err = tx.GetContext(ctx, &attachmentCount, `SELECT COUNT(*) FROM hg_sys_attachment
WHERE tenant_id=? AND id=? AND app_id='workbench' AND member_id=? AND status=1 AND mime_type LIKE 'image/%'`,
input.TenantID, input.QRAttachmentID, input.MemberID)
WHERE id IN (?,?) AND app_id='olons_server' AND member_id=? AND status=1 AND mime_type LIKE 'image/%'`,
input.WechatQRAttachmentID, input.AlipayQRAttachmentID, input.MemberID)
if err != nil {
return nil, err
}
if attachmentCount != 1 {
if attachmentCount != 2 {
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 {
@@ -173,12 +180,15 @@ func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCr
isDefault = 1
}
result, err := tx.ExecContext(ctx, `INSERT INTO hg_app_member_payment_account
(tenant_id,member_id,account_type,bank_code,bank_name,holder_cipher,holder_mask,mobile_cipher,
mobile_mask,account_no_cipher,account_no_hash,account_no_mask,qr_attachment_id,verify_status,
(tenant_id,member_id,account_type,bank_code,bank_name,holder_cipher,holder_mask,
identity_no_cipher,identity_no_hash,identity_no_mask,account_no_cipher,account_no_hash,account_no_mask,
wechat_qr_attachment_id,alipay_qr_attachment_id,verify_status,
is_default,created_by,updated_by)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?)`, input.TenantID, input.MemberID, input.AccountType,
input.BankCode, input.BankName, input.HolderCipher, input.HolderMask, nullableString(input.MobileCipher),
input.MobileMask, input.AccountNoCipher, input.AccountNoHash, input.AccountNoMask, input.QRAttachmentID,
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,2,?,?,?)`, input.TenantID, input.MemberID, input.AccountType,
input.BankCode, input.BankName, input.HolderCipher, input.HolderMask,
input.IdentityNoCipher, input.IdentityNoHash, input.IdentityNoMask,
input.AccountNoCipher, input.AccountNoHash, input.AccountNoMask,
input.WechatQRAttachmentID, input.AlipayQRAttachmentID,
isDefault, input.MemberID, input.MemberID)
if err != nil {
if isDuplicate(err) {
@@ -194,9 +204,10 @@ func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCr
return nil, err
}
return &PaymentAccount{ID: id, AccountType: input.AccountType, BankCode: input.BankCode,
BankName: input.BankName, HolderMask: input.HolderMask, MobileMask: input.MobileMask,
AccountNoMask: input.AccountNoMask, QRAttachmentID: input.QRAttachmentID,
VerifyStatus: 1, IsDefault: isDefault}, nil
BankName: input.BankName, HolderMask: input.HolderMask, IdentityNoMask: input.IdentityNoMask,
AccountNoMask: input.AccountNoMask, WechatQRAttachmentID: input.WechatQRAttachmentID,
AlipayQRAttachmentID: input.AlipayQRAttachmentID,
VerifyStatus: 2, IsDefault: isDefault}, nil
}
func nullableString(value string) any {
@@ -209,8 +220,10 @@ func nullableString(value string) any {
func (s *Store) PaymentAccountForWithdrawal(ctx context.Context, tenantID, memberID, accountID int64) (*PaymentAccount, error) {
var row PaymentAccount
err := s.db.GetContext(ctx, &row, `SELECT id,account_type,bank_code,bank_name,holder_cipher,holder_mask,
COALESCE(mobile_cipher,'') AS mobile_cipher,mobile_mask,account_no_cipher,account_no_hash,
account_no_mask,qr_attachment_id,verify_status,is_default,
COALESCE(identity_no_cipher,'') AS identity_no_cipher,COALESCE(identity_no_hash,'') AS identity_no_hash,
COALESCE(identity_no_mask,'') AS identity_no_mask,account_no_cipher,account_no_hash,account_no_mask,
COALESCE(wechat_qr_attachment_id,0) AS wechat_qr_attachment_id,
COALESCE(alipay_qr_attachment_id,0) AS alipay_qr_attachment_id,verify_status,is_default,
DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at
FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND id=? AND deleted_at IS NULL LIMIT 1`,
@@ -289,8 +302,11 @@ func (s *Store) CreateWithdrawal(ctx context.Context, input WithdrawalCreation)
var account PaymentAccount
err = tx.GetContext(ctx, &account, `SELECT id,account_type,bank_code,bank_name,holder_cipher,holder_mask,
COALESCE(mobile_cipher,'') AS mobile_cipher,mobile_mask,account_no_cipher,account_no_hash,
account_no_mask,qr_attachment_id,verify_status,is_default,'' AS created_at,'' AS updated_at
COALESCE(identity_no_cipher,'') AS identity_no_cipher,COALESCE(identity_no_hash,'') AS identity_no_hash,
COALESCE(identity_no_mask,'') AS identity_no_mask,account_no_cipher,account_no_hash,account_no_mask,
COALESCE(wechat_qr_attachment_id,0) AS wechat_qr_attachment_id,
COALESCE(alipay_qr_attachment_id,0) AS alipay_qr_attachment_id,
verify_status,is_default,'' AS created_at,'' AS updated_at
FROM hg_app_member_payment_account WHERE tenant_id=? AND member_id=? AND id=?
AND verify_status=2 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID, input.PaymentAccountID)
if errors.Is(err, sql.ErrNoRows) {
@@ -301,8 +317,10 @@ func (s *Store) CreateWithdrawal(ctx context.Context, input WithdrawalCreation)
}
if account.AccountType != input.ExpectedAccountType || account.BankCode != input.ExpectedBankCode ||
account.BankName != input.ExpectedBankName || account.HolderCipher != input.ExpectedHolderCipher ||
account.MobileCipher != input.ExpectedMobileCipher || account.AccountNoCipher != input.ExpectedAccountNoCipher ||
account.AccountNoHash != input.ExpectedAccountNoHash || account.QRAttachmentID != input.ExpectedQRAttachmentID {
account.IdentityNoCipher != input.ExpectedIdentityNoCipher || account.IdentityNoHash != input.ExpectedIdentityNoHash ||
account.AccountNoCipher != input.ExpectedAccountNoCipher || account.AccountNoHash != input.ExpectedAccountNoHash ||
account.WechatQRAttachmentID != input.ExpectedWechatQRAttachmentID ||
account.AlipayQRAttachmentID != input.ExpectedAlipayQRAttachmentID {
return nil, ErrConcurrentUpdate
}
var channel WithdrawalChannel
+31 -4
View File
@@ -5,12 +5,13 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/store"
)
const (
@@ -26,6 +27,7 @@ type Repository interface {
TaskHalls(context.Context, int64, int64) ([]store.TaskHall, error)
TaskEligibility(context.Context, int64, int64, int64) (*store.TaskEligibility, error)
ClaimTask(context.Context, store.ClaimTaskInput) (*store.TaskOrder, error)
AutoMatchTaskOrder(context.Context, int64, int64, int64, int, int64, int64) error
TaskOrders(context.Context, int64, int64, int, int, int) (*store.TaskOrderPage, error)
TaskOrder(context.Context, int64, int64, int64) (*store.TaskOrder, error)
TaskStats(context.Context, int64, int64) (*store.TaskStats, error)
@@ -150,6 +152,9 @@ type SubmitResult struct {
type taskPolicy struct {
Enabled bool
MinimumAmount int64
AutoDelay int
AutoMinAmount int64
AutoMaxAmount int64
Start string
End string
Deadline time.Duration
@@ -274,6 +279,24 @@ func (s *Service) Order(ctx context.Context, tenantID, memberID, orderID int64)
if err != nil {
return nil, err
}
if row.OrderStatus == 1 {
policy, policyErr := s.policy(ctx, tenantID)
if policyErr != nil {
return nil, policyErr
}
if policy.Enabled {
err = s.repository.AutoMatchTaskOrder(ctx, tenantID, memberID, orderID, policy.AutoDelay, policy.AutoMinAmount, policy.AutoMaxAmount)
}
if err != nil && !errors.Is(err, store.ErrNoTaskAvailable) {
return nil, err
}
if policy.Enabled && err == nil {
row, err = s.repository.TaskOrder(ctx, tenantID, memberID, orderID)
if err != nil {
return nil, err
}
}
}
ledgers, err := s.repository.OrderLedgers(ctx, tenantID, memberID, []string{row.OrderNo, row.SubmissionNo})
if err != nil {
return nil, err
@@ -300,17 +323,18 @@ func (s *Service) Submit(ctx context.Context, tenantID, memberID, orderID int64,
}
input.EvidenceText = strings.TrimSpace(input.EvidenceText)
if len([]rune(input.EvidenceText)) > 2000 || len(input.EvidenceAttachmentIDs) > 9 ||
(input.EvidenceText == "" && len(input.EvidenceAttachmentIDs) == 0) || !validUniqueIDs(input.EvidenceAttachmentIDs) {
!validUniqueIDs(input.EvidenceAttachmentIDs) {
return nil, ErrEvidenceInvalid
}
now := s.now().UTC()
row, err := s.repository.SubmitTask(ctx, store.TaskSubmissionInput{TenantID: tenantID, MemberID: memberID,
OrderID: orderID, SubmissionNo: newNumber("TS", now), EvidenceText: input.EvidenceText,
AttachmentIDs: input.EvidenceAttachmentIDs})
AttachmentIDs: input.EvidenceAttachmentIDs, LedgerNo: newNumber("WL", now),
IdempotencyKey: fmt.Sprintf("task:%d:auto-settle", orderID), SettledAt: now})
if err != nil {
return nil, err
}
return &SubmitResult{SubmissionNo: row.SubmissionNo, Status: "reviewing",
return &SubmitResult{SubmissionNo: row.SubmissionNo, Status: "settled",
SubmittedAt: row.SubmittedAt.Format(time.RFC3339), OrderStatus: orderStatusName(row.OrderStatus)}, nil
}
@@ -331,6 +355,9 @@ func (s *Service) policy(ctx context.Context, tenantID int64) (taskPolicy, error
end := clockValue(findValue(config, "task_business_end_at", "business_end_at", "taskBusinessEndAt"), "23:59:59")
return taskPolicy{Enabled: boolValue(findValue(config, "task_enabled", "taskEnabled", "TASK")),
MinimumAmount: max64(0, int64Value(findValue(config, "task_minimum_amount", "minimum_balance_amount", "taskMinimumAmount"))),
AutoDelay: max(0, intValue(findValue(config, "auto_order_delay_seconds", "autoOrderDelaySeconds"))),
AutoMinAmount: max64(0, int64Value(findValue(config, "auto_order_min_amount", "autoOrderMinAmount"))),
AutoMaxAmount: max64(0, int64Value(findValue(config, "auto_order_max_amount", "autoOrderMaxAmount"))),
Start: start, End: end, Deadline: time.Duration(minutes) * time.Minute}, nil
}
+28 -11
View File
@@ -4,10 +4,11 @@ import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"git.118im.com/im_team/im_workbench/internal/store"
"git.118im.com/im_team/olons_server/internal/store"
)
type fakeRepository struct {
@@ -29,9 +30,20 @@ func (f *fakeRepository) TaskEligibility(context.Context, int64, int64, int64) (
}
func (f *fakeRepository) ClaimTask(_ context.Context, input store.ClaimTaskInput) (*store.TaskOrder, error) {
f.claimed = input
return &store.TaskOrder{ID: 9, OrderNo: input.OrderNo, OrderStatus: 2, Currency: "CNY",
return &store.TaskOrder{ID: 9, OrderNo: input.OrderNo, OrderStatus: 1, Currency: "CNY",
DeadlineAt: input.DeadlineAt.Format(time.RFC3339)}, nil
}
func (f *fakeRepository) AutoMatchTaskOrder(context.Context, int64, int64, int64, int, int64, int64) error {
if f.order != nil && f.order.OrderStatus == 1 {
f.order.OrderStatus = 2
f.order.ProductID = 7
f.order.ProductName = "自动匹配商品"
f.order.Quantity = 1
f.order.TotalAmount = 10000
f.order.CommissionAmount = 100
}
return nil
}
func (f *fakeRepository) TaskOrders(context.Context, int64, int64, int, int, int) (*store.TaskOrderPage, error) {
return &store.TaskOrderPage{Items: []store.TaskOrder{*f.order}, Total: 1}, nil
}
@@ -43,7 +55,7 @@ func (f *fakeRepository) TaskStats(context.Context, int64, int64) (*store.TaskSt
}
func (f *fakeRepository) SubmitTask(_ context.Context, input store.TaskSubmissionInput) (*store.TaskSubmissionResult, error) {
f.submitted = input
return &store.TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: 3,
return &store.TaskSubmissionResult{SubmissionNo: input.SubmissionNo, OrderStatus: 4,
SubmittedAt: time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)}, nil
}
func (f *fakeRepository) OrderLedgers(context.Context, int64, int64, []string) ([]map[string]any, error) {
@@ -75,7 +87,7 @@ func TestClaimFailsClosedWithoutTaskSwitch(t *testing.T) {
}
}
func TestClaimBuildsPendingOrder(t *testing.T) {
func TestClaimBuildsMatchingOrder(t *testing.T) {
repository := &fakeRepository{config: json.RawMessage(`{"task_enabled":true,"task_deadline_minutes":20}`)}
service := New(repository)
now := time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)
@@ -85,29 +97,34 @@ func TestClaimBuildsPendingOrder(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if result.Status != "pending" || repository.claimed.HallID != 2 ||
if result.Status != "matching" || repository.claimed.HallID != 2 ||
!repository.claimed.DeadlineAt.Equal(now.Add(20*time.Minute)) {
t.Fatalf("unexpected claim: result=%#v input=%#v", result, repository.claimed)
}
}
func TestSubmitValidatesAndNormalizesEvidence(t *testing.T) {
func TestSubmitAutomaticallySettlesWithoutEvidence(t *testing.T) {
repository := &fakeRepository{}
service := New(repository)
service.now = func() time.Time { return time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC) }
if _, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{}); !errors.Is(err, ErrEvidenceInvalid) {
t.Fatalf("expected evidence error, got %v", err)
}
result, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{EvidenceText: " 已完成 ", EvidenceAttachmentIDs: []int64{3}})
result, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{})
if err != nil {
t.Fatal(err)
}
if result.Status != "reviewing" || result.OrderStatus != "processing" || repository.submitted.EvidenceText != "已完成" {
if result.Status != "settled" || result.OrderStatus != "completed" || repository.submitted.IdempotencyKey != "task:9:auto-settle" || repository.submitted.LedgerNo == "" {
t.Fatalf("unexpected submission: result=%#v input=%#v", result, repository.submitted)
}
}
func TestSubmitStillValidatesOptionalEvidence(t *testing.T) {
service := New(&fakeRepository{})
tooLong := strings.Repeat("字", 2001)
if _, err := service.Submit(context.Background(), 0, 10, 9, SubmitInput{EvidenceText: tooLong}); !errors.Is(err, ErrEvidenceInvalid) {
t.Fatalf("expected evidence error, got %v", err)
}
}
func TestOrdersRejectsUnknownStatus(t *testing.T) {
service := New(&fakeRepository{})
_, err := service.Orders(context.Background(), 0, 10, 1, 20, "not-a-status")
+14
View File
@@ -2,6 +2,7 @@ import axios, { AxiosError, AxiosRequestConfig } from 'axios';
const TOKEN_KEY = 'olons_web_session';
const DEVICE_KEY = 'olons_web_device';
const LOGIN_NOTICE_PENDING_KEY = 'olons_login_notice_pending';
const reasonMessages: Record<string, string> = {
AUTH_REQUIRED: '登录已失效,请重新登录',
@@ -19,6 +20,9 @@ const reasonMessages: Record<string, string> = {
ORDER_STATE_INVALID: '当前任务状态不允许此操作',
TRADE_PASSWORD_NOT_SET: '请先设置支付密码',
TRADE_PASSWORD_INVALID: '支付密码不正确',
PAYMENT_ACCOUNT_FIELDS_INVALID: '绑卡信息填写不完整或格式不正确',
QR_ATTACHMENT_INVALID: '二维码图片无效,请重新上传',
IMAGE_INVALID: '图片格式或大小不符合要求',
PAYMENT_ACCOUNT_INVALID: '收款账户未核验或不可用',
WITHDRAWAL_DISABLED: '取出功能暂未开放',
WITHDRAWAL_AMOUNT_INVALID: '取出金额不符合当前规则',
@@ -132,16 +136,26 @@ function getDeviceID() {
export function saveSession(token: string) {
sessionStorage.setItem(TOKEN_KEY, token);
sessionStorage.setItem(LOGIN_NOTICE_PENDING_KEY, '1');
}
export function clearSession() {
sessionStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(LOGIN_NOTICE_PENDING_KEY);
}
export function hasSession() {
return Boolean(sessionStorage.getItem(TOKEN_KEY));
}
export function shouldShowLoginNotice() {
return sessionStorage.getItem(LOGIN_NOTICE_PENDING_KEY) !== '0';
}
export function completeLoginNotice() {
sessionStorage.setItem(LOGIN_NOTICE_PENDING_KEY, '0');
}
export function idempotencyKey() {
return crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

@@ -0,0 +1,148 @@
<template>
<Teleport to="body">
<Transition name="announcement-fade">
<div v-if="show" class="m-announcement-mask" role="presentation">
<section
class="m-announcement-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="announcement-title"
aria-describedby="announcement-content"
>
<h2 id="announcement-title" class="m-announcement-sr-title">
{{ notice?.title || '公告通知' }}
</h2>
<img class="m-announcement-board" :src="announcementBoard" alt="" />
<div class="m-announcement-body">
<div id="announcement-content" class="m-announcement-content">
{{ notice?.content }}
</div>
<button type="button" @click="$emit('read')">我已阅读</button>
</div>
</section>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { onBeforeUnmount, watch } from 'vue';
import announcementBoard from '../assets/announcement-popup@2x.png';
export interface AnnouncementNotice {
id: number;
title: string;
content: string;
}
const props = defineProps<{
show: boolean;
notice?: AnnouncementNotice | null;
}>();
defineEmits<{ read: [] }>();
watch(
() => props.show,
(show) => {
document.body.classList.toggle('m-announcement-open', show);
},
{ immediate: true }
);
onBeforeUnmount(() => document.body.classList.remove('m-announcement-open'));
</script>
<style scoped>
.m-announcement-mask {
position: fixed;
inset: 0;
z-index: 3000;
display: grid;
place-items: center;
padding: 20px 16px;
background: rgba(0, 0, 0, 0.62);
}
.m-announcement-dialog {
position: relative;
width: min(312px, calc(100vw - 32px), calc((100vh - 32px) * 2 / 3));
aspect-ratio: 2 / 3;
}
.m-announcement-board {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: fill;
pointer-events: none;
user-select: none;
}
.m-announcement-body {
position: absolute;
inset: 31.2% 16.7% 13.6%;
display: flex;
min-height: 0;
flex-direction: column;
}
.m-announcement-content {
flex: 1;
min-height: 0;
overflow-y: auto;
color: #282828;
font-size: clamp(14px, 4.25vw, 16px);
line-height: 1.42;
text-align: left;
white-space: pre-line;
scrollbar-width: none;
}
.m-announcement-content::-webkit-scrollbar {
display: none;
}
.m-announcement-body button {
flex: none;
width: 100%;
height: 41px;
margin-top: 10px;
border: 0;
border-radius: 24px;
background: linear-gradient(90deg, #ff972c 0%, #f45119 100%);
box-shadow: 0 5px 12px rgba(244, 81, 25, 0.2);
color: #fff;
font-size: 16px;
cursor: pointer;
}
.m-announcement-sr-title {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.announcement-fade-enter-active,
.announcement-fade-leave-active {
transition: opacity 0.2s ease;
}
.announcement-fade-enter-from,
.announcement-fade-leave-to {
opacity: 0;
}
</style>
<style>
body.m-announcement-open {
overflow: hidden;
}
</style>
+146
View File
@@ -1008,6 +1008,91 @@ body {
text-align: right;
overflow-wrap: anywhere;
}
.m-order-submit-panel {
padding: 17px;
border-radius: 8px;
background: #fff8ea;
box-shadow: 0 4px 18px rgba(73, 54, 31, 0.07);
}
.m-order-submit-panel strong {
color: #6f3f12;
font-size: 17px;
}
.m-order-submit-panel p {
margin: 7px 0 15px;
color: #8b765e;
font-size: 14px;
line-height: 1.6;
}
.m-order-submit-panel > button,
.m-order-submit-actions button:last-child {
border: 0;
color: #fff;
background: linear-gradient(90deg, #ffa32b, #f15b20);
}
.m-order-submit-panel > button {
width: 100%;
min-height: 46px;
border-radius: 24px;
font-size: 16px;
}
.m-order-submit-panel button:disabled,
.m-order-submit-actions button:disabled {
opacity: 0.6;
}
.m-order-submit-confirm {
width: min(90vw, 430px);
overflow: hidden;
border-radius: 18px;
background: #fff;
box-shadow: 0 16px 50px rgba(70, 40, 10, 0.2);
}
.m-order-submit-confirm h2 {
margin: 0;
padding: 24px 22px 8px;
text-align: center;
font-size: 21px;
}
.m-order-submit-confirm > p {
margin: 0;
padding: 0 25px 18px;
color: #8a8a8a;
text-align: center;
line-height: 1.55;
}
.m-order-submit-confirm dl {
margin: 0 20px 22px;
padding: 13px 16px;
border-radius: 10px;
background: #faf8f5;
}
.m-order-submit-confirm dl > div {
min-height: 38px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.m-order-submit-confirm dt {
flex: 0 0 auto;
color: #777;
}
.m-order-submit-confirm dd {
margin: 0;
overflow-wrap: anywhere;
text-align: right;
}
.m-order-submit-actions {
display: grid;
grid-template-columns: 1fr 1fr;
border-top: 1px solid #f0ebe4;
}
.m-order-submit-actions button {
min-height: 54px;
border: 0;
background: #fff;
font-size: 16px;
}
.m-btn-link {
display: grid;
place-items: center;
@@ -1449,9 +1534,70 @@ body {
color: var(--m-muted);
line-height: 1.6;
}
.m-form-hint a {
color: var(--m-primary);
font-weight: 600;
}
.m-plain-form > .m-form-error {
margin: 15px 30px;
}
.m-bank-form {
margin-top: 18px;
padding-bottom: 38px;
}
.m-bank-form > label {
min-height: 66px;
grid-template-columns: 125px 1fr;
border-bottom: 0;
}
.m-bank-form__divider {
height: 16px;
margin: 0;
background: #f6f6f6;
}
.m-qr-field {
padding: 16px 15px 0;
}
.m-qr-field h3 {
margin: 0 0 10px;
font-size: 16px;
font-weight: 400;
}
.m-qr-upload {
width: 100%;
aspect-ratio: 2.12 / 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px dashed #d1d1d1;
border-radius: 5px;
background: #f8f8f8;
color: #b8b8b8;
cursor: pointer;
}
.m-qr-upload input {
display: none;
}
.m-qr-upload > span {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
}
.m-qr-upload .n-icon {
font-size: 50px;
color: #ddd;
}
.m-qr-upload b {
font-size: 15px;
font-weight: 400;
}
.m-qr-upload img {
width: 100%;
height: 100%;
object-fit: contain;
}
.m-verify label:nth-child(2) {
grid-template-columns: 125px 1fr auto;
}
+35 -9
View File
@@ -5,7 +5,7 @@
<img :src="mobileAssets.claimMachine" alt="每日参与任务,赚取更多佣金" />
<button
type="button"
:disabled="claiming || !halls.length || eligibility?.eligible === false"
:disabled="claiming || !eligibleHalls.length"
aria-label="自动领取任务"
@click="claim"
>
@@ -22,7 +22,7 @@
</div>
</section>
<div v-if="eligibility?.eligible === false" class="m-claim2-warning">
<div v-if="halls.length && !eligibleHalls.length && eligibility?.eligible === false" class="m-claim2-warning">
<n-icon :component="AlertCircleOutline" />
<span>{{ eligibility.message || '暂不满足领取条件' }}</span>
<button v-if="eligibility.pending_order_id" type="button" @click="openPendingOrder">查看任务</button>
@@ -100,7 +100,7 @@
import { useRouter } from 'vue-router';
import { AlertCircleOutline, ImageOutline } from '@vicons/ionicons5';
import AsyncState from '../components/AsyncState.vue';
import { dateTime, errorText, money, request, safeImage } from '../api';
import { MobileApiError, dateTime, errorText, money, request, safeImage } from '../api';
import { mobileAssets } from '../assets';
type ResultState = 'matching' | 'success' | 'failed' | 'cancelled';
@@ -112,6 +112,7 @@
const halls = ref<any[]>([]);
const stats = ref<any>();
const eligibility = ref<any>();
const eligibilityByHall = ref<Record<number, any>>({});
const order = ref<any>();
const showResult = ref(false);
const resultState = ref<ResultState>('matching');
@@ -128,6 +129,9 @@
{ label: '今日佣金', value: money(stats.value?.today_commission_amount), icon: mobileAssets.claimToday },
{ label: '今日领取数', value: stats.value?.today_claim_count ?? 0, icon: mobileAssets.claimCount },
]);
const eligibleHalls = computed(() =>
halls.value.filter((hall) => eligibilityByHall.value[hall.id]?.eligible)
);
const businessHours = computed(() => {
const start = String(eligibility.value?.business_start_at || '09:00').slice(0, 5);
const end = String(eligibility.value?.business_end_at || '21:00').slice(0, 5);
@@ -154,7 +158,7 @@
]);
halls.value = hallData || [];
stats.value = statData;
if (halls.value.length) await loadEligibility(halls.value[0].id);
if (halls.value.length) await loadEligibilities();
} catch (err) {
error.value = errorText(err);
} finally {
@@ -162,8 +166,15 @@
}
}
async function loadEligibility(hallID: number) {
eligibility.value = await request({ url: '/v1/tasks/eligibility', params: { hall_id: hallID } });
async function loadEligibilities() {
const entries = await Promise.all(
halls.value.map(async (hall) => [
hall.id,
await request<any>({ url: '/v1/tasks/eligibility', params: { hall_id: hall.id } }),
] as const)
);
eligibilityByHall.value = Object.fromEntries(entries);
eligibility.value = entries.find(([, value]) => value?.eligible)?.[1] || entries[0]?.[1];
}
async function claim() {
@@ -171,18 +182,33 @@
claiming.value = true;
pollFinished.value = false;
try {
await loadEligibility(halls.value[0].id);
if (!eligibility.value?.eligible) {
await loadEligibilities();
const candidates = eligibleHalls.value;
if (!candidates.length) {
window.$message.warning(eligibility.value?.message || '暂不满足领取条件');
return;
}
openMatching();
let stockError: unknown;
for (const hall of candidates) {
eligibility.value = eligibilityByHall.value[hall.id];
try {
order.value = await request<any>({
url: '/v1/task-orders/claim',
method: 'POST',
data: { hall_id: halls.value[0].id },
data: { hall_id: hall.id },
});
handleOrder(order.value);
return;
} catch (err) {
if (err instanceof MobileApiError && err.reason === 'NO_TASK_AVAILABLE') {
stockError = err;
continue;
}
throw err;
}
}
throw stockError || new Error('当前暂无可领取任务');
} catch (err) {
failureMessage.value = errorText(err);
resultState.value = 'failed';
+66 -32
View File
@@ -43,38 +43,38 @@
</div>
</AsyncState>
<form v-else-if="formType === 'bank'" class="m-plain-form" @submit.prevent="saveBank">
<form v-else-if="formType === 'bank'" class="m-plain-form m-bank-form" @submit.prevent="saveBank">
<label
><span>姓名</span><input v-model.trim="bank.holder_name" placeholder="请输入姓名"
/></label>
<label
><span>身份证号</span
><input v-model.trim="bank.identity_no" maxlength="18" placeholder="请输入你的身份证号"
/></label>
<div class="m-bank-form__divider"></div>
<label
><span>银行卡号</span
><input v-model.trim="bank.account_no" inputmode="numeric" placeholder="请输入银行卡号"
><input v-model.trim="bank.account_no" inputmode="numeric" placeholder="请输入你的银行卡号"
/></label>
<label
><span>开户行</span><input v-model.trim="bank.bank_name" placeholder="请输入开户行"
/></label>
<label
><span>银行编码</span><input v-model.trim="bank.bank_code" placeholder="例如 ICBC"
/></label>
<label
><span>预留手机号</span
><input
v-model.trim="bank.reserved_phone"
inputmode="numeric"
maxlength="11"
placeholder="请输入预留手机号(选填)"
/></label>
<label
><span>交易密码</span
><input
v-model="bank.trade_password"
type="password"
inputmode="numeric"
maxlength="6"
placeholder="请输入 6 位交易密码"
/></label>
<p class="m-form-hint">微信、支付宝二维码上传能力将在上传接口开放后提供。</p>
<section class="m-qr-field">
<h3>微信二维码</h3>
<label class="m-qr-upload">
<input type="file" accept="image/jpeg,image/png,image/webp" @change="uploadQRCode('wechat', $event)" />
<img v-if="qr.wechat.url" :src="safeImage(qr.wechat.url)" alt="微信二维码" />
<span v-else><n-icon :component="CameraOutline" /><b>{{ qr.wechat.uploading ? '上传中...' : '点击上传' }}</b></span>
</label>
</section>
<section class="m-qr-field">
<h3>支付宝二维码</h3>
<label class="m-qr-upload">
<input type="file" accept="image/jpeg,image/png,image/webp" @change="uploadQRCode('alipay', $event)" />
<img v-if="qr.alipay.url" :src="safeImage(qr.alipay.url)" alt="支付宝二维码" />
<span v-else><n-icon :component="CameraOutline" /><b>{{ qr.alipay.uploading ? '上传中...' : '点击上传' }}</b></span>
</label>
</section>
<p v-if="error" class="m-form-error">{{ error }}</p
><button class="m-primary-btn" :disabled="submitting">{{
submitting ? '提交中...' : '确认绑定'
@@ -208,6 +208,7 @@
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
CameraOutline,
ChevronForwardOutline,
KeyOutline,
LockClosedOutline,
@@ -239,14 +240,16 @@
const content = ref<any>();
const resetPassword = ref('');
const bank = reactive({
account_type: 'bank_card',
bank_code: '',
bank_name: '',
holder_name: '',
identity_no: '',
account_no: '',
reserved_phone: '',
trade_password: '',
qr_attachment_id: 0,
wechat_qr_attachment_id: 0,
alipay_qr_attachment_id: 0,
});
const qr = reactive({
wechat: { uploading: false, url: '' },
alipay: { uploading: false, url: '' },
});
const trade = reactive({ login_password: '', old_trade_password: '', new_trade_password: '' });
const loginPassword = reactive({ old_password: '', new_password: '', confirm_password: '' });
@@ -266,24 +269,55 @@
error.value = '';
if (
!bank.holder_name ||
!/^[1-9]\d{14}(?:\d{2}[\dXx])?$/.test(bank.identity_no.replace(/[ -]/g, '')) ||
!/^[0-9 -]{8,36}$/.test(bank.account_no) ||
!bank.bank_name ||
!/^[0-9]{6}$/.test(bank.trade_password)
!bank.wechat_qr_attachment_id ||
!bank.alipay_qr_attachment_id
) {
error.value = '请完整填写姓名、银行卡、开户行和 6 位交易密码';
error.value = '请完整填写绑卡信息并上传微信、支付宝二维码';
return;
}
submitting.value = true;
try {
await request({ url: '/v1/payment-accounts', method: 'POST', data: bank });
window.$message?.success('提交成功,等待后台核验');
router.back();
window.$message?.success('银行卡绑定成功');
await router.replace('/app/profile/withdrawal');
} catch (err) {
error.value = errorText(err);
} finally {
submitting.value = false;
}
}
async function uploadQRCode(kind: 'wechat' | 'alipay', event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 5 * 1024 * 1024) {
error.value = '二维码仅支持 5MB 以内的 JPG、PNG 或 WebP 图片';
return;
}
const target = qr[kind];
target.uploading = true;
error.value = '';
try {
const form = new FormData();
form.append('file', file);
const uploaded = await request<{ id: number; url: string }>({
url: '/v1/attachments/images',
method: 'POST',
data: form,
});
target.url = uploaded.url;
if (kind === 'wechat') bank.wechat_qr_attachment_id = uploaded.id;
else bank.alipay_qr_attachment_id = uploaded.id;
} catch (err) {
error.value = errorText(err);
} finally {
target.uploading = false;
}
}
async function saveTradePassword() {
error.value = '';
if (!/^\d{6}$/.test(trade.new_trade_password) || trade.login_password.length < 6) {
+31 -2
View File
@@ -1,5 +1,10 @@
<template>
<section class="m-home">
<AnnouncementPopup
:show="Boolean(popupNotice)"
:notice="popupNotice"
@read="markPopupRead"
/>
<div class="m-home-hero" :style="bannerStyle">
<label class="m-search">
<n-icon :component="SearchOutline" />
@@ -81,7 +86,17 @@
import { useRouter } from 'vue-router';
import { SearchOutline, MegaphoneOutline } from '@vicons/ionicons5';
import AsyncState from '../components/AsyncState.vue';
import { errorText, money, request, safeImage } from '../api';
import AnnouncementPopup, {
type AnnouncementNotice,
} from '../components/AnnouncementPopup.vue';
import {
completeLoginNotice,
errorText,
money,
request,
safeImage,
shouldShowLoginNotice,
} from '../api';
import { mobileAssets } from '../assets';
interface HomeData {
@@ -108,6 +123,7 @@
const loading = ref(true);
const error = ref('');
const leaderboard = ref<LeaderboardEntry[]>([]);
const popupNotice = ref<AnnouncementNotice | null>(null);
const formatCount = (value: number) => new Intl.NumberFormat('zh-CN').format(value || 0);
const bannerStyle = computed(() => {
const url = safeImage(home.value?.banners?.[0]?.image_url);
@@ -166,17 +182,30 @@
loading.value = true;
error.value = '';
try {
const [homeData, rankData] = await Promise.all([
const noticeRequest = shouldShowLoginNotice()
? request<AnnouncementNotice | null>({ url: '/v1/notices/popup' }).catch(() => null)
: Promise.resolve(null);
const [homeData, rankData, noticeData] = await Promise.all([
request<HomeData>({ url: '/v1/home' }),
request<LeaderboardEntry[]>({ url: '/v1/leaderboard' }).catch(() => []),
noticeRequest,
]);
home.value = homeData;
leaderboard.value = rankData || [];
popupNotice.value = noticeData;
} catch (err) {
error.value = errorText(err);
} finally {
loading.value = false;
}
}
function markPopupRead() {
if (!popupNotice.value) return;
const noticeId = popupNotice.value.id;
popupNotice.value = null;
completeLoginNotice();
request<void>({ url: `/v1/notices/${noticeId}/read`, method: 'POST' }).catch(() => undefined);
}
onMounted(load);
</script>
+52 -1
View File
@@ -103,8 +103,31 @@
><b>{{ detail.product?.specification || detail.product?.spec || '--' }}</b></div
>
</section>
<section v-if="canSubmit" class="m-order-submit-panel">
<div>
<strong>任务已匹配成功</strong>
<p>请确认任务信息,提交后佣金将自动结算到可用余额。</p>
</div>
<button type="button" :disabled="submitting" @click="showSubmitConfirm = true">立即提交</button>
</section>
</div>
</AsyncState>
<n-modal v-model:show="showSubmitConfirm" :mask-closable="!submitting">
<section class="m-order-submit-confirm">
<h2>确认提交任务</h2>
<p>提交后系统将自动完成订单并发放佣金,请确认订单信息无误。</p>
<dl>
<div><dt>记录编号</dt><dd>{{ detail?.order_no || '--' }}</dd></div>
<div><dt>任务商品</dt><dd>{{ detail?.product?.name || '--' }}</dd></div>
<div><dt>预计佣金</dt><dd>{{ money(detail?.commission_amount, detail?.currency) }}</dd></div>
</dl>
<div class="m-order-submit-actions">
<button type="button" :disabled="submitting" @click="showSubmitConfirm = false">暂不提交</button>
<button type="button" :disabled="submitting" @click="submitOrder">{{ submitting ? '提交中...' : '立即提交' }}</button>
</div>
</section>
</n-modal>
</section>
</template>
@@ -114,7 +137,7 @@
import { ImageOutline } from '@vicons/ionicons5';
import PageHeader from '../components/PageHeader.vue';
import AsyncState from '../components/AsyncState.vue';
import { dateTime, errorText, money, request, safeImage } from '../api';
import { MobileApiError, dateTime, errorText, money, request, safeImage } from '../api';
const route = useRoute();
const isDetail = computed(() => Boolean(route.params.id));
@@ -124,6 +147,8 @@
const orders = ref<any[]>([]);
const detail = ref<any>();
const stats = ref<any>();
const showSubmitConfirm = ref(false);
const submitting = ref(false);
let listController: AbortController | undefined;
const tabs = [
{ label: '全部', value: 'all' },
@@ -149,6 +174,9 @@
failed: '失败',
};
const statusText = (value: string) => labels[value] || value;
const canSubmit = computed(
() => detail.value?.status === 'pending' && detail.value?.allowed_actions?.includes('submit')
);
async function loadList() {
listController?.abort();
const controller = new AbortController();
@@ -185,11 +213,34 @@
loading.value = false;
}
}
async function submitOrder() {
if (!detail.value?.id || submitting.value) return;
submitting.value = true;
try {
await request({
url: `/v1/task-orders/${detail.value.id}/submissions`,
method: 'POST',
data: {},
});
showSubmitConfirm.value = false;
window.$message.success('提交成功,佣金已自动入账');
await loadDetail();
} catch (err) {
window.$message.error(errorText(err));
if (err instanceof MobileApiError && err.reason === 'ORDER_EXPIRED') {
showSubmitConfirm.value = false;
await loadDetail();
}
} finally {
submitting.value = false;
}
}
function changeTab(value: string) {
status.value = value;
loadList();
}
function load() {
showSubmitConfirm.value = false;
if (isDetail.value) loadDetail();
else loadList();
}
+1 -1
View File
@@ -104,7 +104,7 @@
remark: `姓名:${form.name}`,
},
});
window.$message?.success(`储入订单 ${order.recharge_no} 已创建`);
window.$message?.success(`储入申请 ${order.recharge_no} 已提交审核`);
form.amount = '';
form.name = '';
} catch (err) {
+16 -2
View File
@@ -41,7 +41,12 @@
<span>加载中</span>
</div>
<n-modal v-model:show="showConfirm">
<n-modal
v-model:show="showConfirm"
:auto-focus="false"
:trap-focus="false"
@after-enter="focusTradePassword"
>
<form class="m-withdraw-confirm m-card" @submit.prevent="submitWithdrawal">
<h2>确认取出</h2>
<div><span>申请金额</span><b>{{ money(quote?.request_amount) }}</b></div>
@@ -50,8 +55,11 @@
<label>
<span>支付密码</span>
<input
ref="tradePasswordInput"
v-model="form.tradePassword"
type="password"
name="trade-password"
autocomplete="off"
inputmode="numeric"
maxlength="6"
placeholder="请输入 6 位支付密码"
@@ -65,7 +73,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ChevronForwardOutline } from '@vicons/ionicons5';
import PageHeader from '../components/PageHeader.vue';
@@ -83,6 +91,7 @@
const quote = ref<any>();
const showConfirm = ref(false);
const withdrawalKey = ref('');
const tradePasswordInput = ref<HTMLInputElement>();
const form = reactive({ paymentAccountId: 0, amountYuan: '', tradePassword: '' });
const selectedAccount = computed(() =>
accounts.value.find((item) => item.id === form.paymentAccountId)
@@ -101,6 +110,10 @@
function openAccountPicker() {
if (!selectedAccount.value) router.push('/app/profile/bank-card');
}
async function focusTradePassword() {
await nextTick();
tradePasswordInput.value?.focus();
}
async function load() {
loading.value = true;
loadError.value = '';
@@ -138,6 +151,7 @@
data: { payment_account_id: form.paymentAccountId, amount, currency: 'CNY' },
});
withdrawalKey.value = idempotencyKey();
form.tradePassword = '';
showConfirm.value = true;
} catch (err) {
actionError.value = errorText(err);
+4
View File
@@ -15,6 +15,10 @@ export default defineConfig(({ mode }) => {
target: env.VITE_API_TARGET || 'http://localhost:6061',
changeOrigin: true,
},
'/uploads': {
target: env.VITE_API_TARGET || 'http://localhost:6061',
changeOrigin: true,
},
},
},
build: {