diff --git a/.gitignore b/.gitignore index 205c5f3..6e7234d 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,8 @@ go.work.sum # env file .env .idea/ +.DS_Store +storage/uploads/ # ---> Node # Logs diff --git a/Dockerfile b/Dockerfile index 509f73d..e9bddc6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile index 5291024..9418520 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 902a3ec..2ad3e81 100644 --- a/README.md +++ b/README.md @@ -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='' +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='' ``` -`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: X-Device-ID: ``` -默认租户通过 `WORKBENCH_DEFAULT_TENANT_ID` 配置,现有 `olons_admin` 数据使用 `tenant_id=0`。服务不接受客户端传入租户身份。 +默认租户通过 `OLONS_SERVER_DEFAULT_TENANT_ID` 配置,现有 `olons_admin` 数据使用 `tenant_id=0`。服务不接受客户端传入租户身份。 ## 验证 diff --git a/cmd/migrate-member-mobile/main.go b/cmd/migrate-member-mobile/main.go index 6923968..9e2aa7c 100644 --- a/cmd/migrate-member-mobile/main.go +++ b/cmd/migrate-member-mobile/main.go @@ -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) } diff --git a/cmd/workbench/main.go b/cmd/olons_server/main.go similarity index 86% rename from cmd/workbench/main.go rename to cmd/olons_server/main.go index 4af2b4d..3883adb 100644 --- a/cmd/workbench/main.go +++ b/cmd/olons_server/main.go @@ -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 } diff --git a/configs/workbench.local.json b/configs/olons_server.local.json similarity index 68% rename from configs/workbench.local.json rename to configs/olons_server.local.json index 1065a2a..730defc 100644 --- a/configs/workbench.local.json +++ b/configs/olons_server.local.json @@ -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 } diff --git a/configs/workbench.test.json b/configs/olons_server.test.json similarity index 60% rename from configs/workbench.test.json rename to configs/olons_server.test.json index bf0ac23..1e9d004 100644 --- a/configs/workbench.test.json +++ b/configs/olons_server.test.json @@ -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 } } diff --git a/docs/all/pic/image_124.png b/docs/all/pic/image_124.png new file mode 100644 index 0000000..57993ef Binary files /dev/null and b/docs/all/pic/image_124.png differ diff --git a/docs/all/pic/image_124@2x.png b/docs/all/pic/image_124@2x.png new file mode 100644 index 0000000..a80e474 Binary files /dev/null and b/docs/all/pic/image_124@2x.png differ diff --git a/docs/all/pic/关闭图标.png b/docs/all/pic/关闭图标.png new file mode 100644 index 0000000..186c25d Binary files /dev/null and b/docs/all/pic/关闭图标.png differ diff --git a/docs/all/pic/关闭图标@2x.png b/docs/all/pic/关闭图标@2x.png new file mode 100644 index 0000000..25ad020 Binary files /dev/null and b/docs/all/pic/关闭图标@2x.png differ diff --git a/docs/all/首页-弹窗.png b/docs/all/首页-弹窗.png new file mode 100644 index 0000000..d1f8c72 Binary files /dev/null and b/docs/all/首页-弹窗.png differ diff --git a/docs/移动端任务与账户平台前后端对接文档.md b/docs/移动端任务与账户平台前后端对接文档.md index d107269..c8c191e 100644 --- a/docs/移动端任务与账户平台前后端对接文档.md +++ b/docs/移动端任务与账户平台前后端对接文档.md @@ -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" diff --git a/docs/移动端任务与账户平台需求文档.md b/docs/移动端任务与账户平台需求文档.md index c94380c..7431a23 100644 --- a/docs/移动端任务与账户平台需求文档.md +++ b/docs/移动端任务与账户平台需求文档.md @@ -111,9 +111,9 @@ | **状态** | **用户可见文案** | **允许操作** | **系统约束** | | --- | --- | --- | --- | | 匹配中 | 正在匹配任务 | 取消等待 | 只允许一个匹配请求 | -| 待处理 | 请在截止时间前完成 | 查看详情、提交 | 倒计时与服务端时间一致 | -| 处理中 | 已提交,等待确认 | 查看详情、联系客服 | 防重复提交 | -| 已完成 | 本金与佣金已入账 | 查看流水 | 三笔或组合流水必须可核对 | +| 待处理 | 请在截止时间前完成 | 查看详情、立即提交 | 倒计时与服务端时间一致 | +| 处理中 | 系统正在结算 | 查看详情、联系客服 | 防重复提交;正常情况为瞬时状态 | +| 已完成 | 佣金已入账 | 查看流水 | 订单、提交记录和佣金流水必须可核对 | | 超时或冻结 | 显示原因与处理路径 | 申诉、联系客服 | 需要人工复核和审计 | | 失败或取消 | 显示失败原因 | 重新领取 | 不得产生不一致余额 | diff --git a/go.mod b/go.mod index 1a1b51c..565b324 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module git.118im.com/im_team/im_workbench +module git.118im.com/im_team/olons_server go 1.26.0 diff --git a/internal/accounts/profile_test.go b/internal/accounts/profile_test.go index 9e14e43..e563f4e 100644 --- a/internal/accounts/profile_test.go +++ b/internal/accounts/profile_test.go @@ -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" ) diff --git a/internal/accounts/service.go b/internal/accounts/service.go index f3a7cab..6525cd2 100644 --- a/internal/accounts/service.go +++ b/internal/accounts/service.go @@ -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) { diff --git a/internal/accounts/service_test.go b/internal/accounts/service_test.go index 011f664..77aaf10 100644 --- a/internal/accounts/service_test.go +++ b/internal/accounts/service_test.go @@ -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{} diff --git a/internal/app/app.go b/internal/app/app.go index d2f303b..c241830 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 = "/" } diff --git a/internal/attachments/service.go b/internal/attachments/service.go new file mode 100644 index 0000000..bfd9fde --- /dev/null +++ b/internal/attachments/service.go @@ -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 +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 9c44325..7fff5f0 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -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) { diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 0461871..9aae9a0 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -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) } } diff --git a/internal/config/config.go b/internal/config/config.go index 872d5ed..5c2feb4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/finance/service.go b/internal/finance/service.go index bf2db1c..e5fdeea 100644 --- a/internal/finance/service.go +++ b/internal/finance/service.go @@ -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,29 +66,28 @@ type Service struct { } type PaymentAccountInput struct { - AccountType string `json:"account_type"` - BankCode string `json:"bank_code"` - BankName string `json:"bank_name"` - HolderName string `json:"holder_name"` - AccountNo string `json:"account_no"` - ReservedPhone string `json:"reserved_phone"` - QRAttachmentID int64 `json:"qr_attachment_id"` - TradePassword string `json:"trade_password"` + BankName string `json:"bank_name"` + HolderName string `json:"holder_name"` + IdentityNo string `json:"identity_no"` + AccountNo string `json:"account_no"` + WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"` + AlipayQRAttachmentID int64 `json:"alipay_qr_attachment_id"` } type PaymentAccount struct { - ID int64 `json:"id"` - AccountType string `json:"account_type"` - BankCode string `json:"bank_code"` - BankName string `json:"bank_name"` - HolderMask string `json:"holder_mask"` - MobileMask string `json:"mobile_mask"` - AccountNoMask string `json:"account_no_mask"` - QRAttachmentID int64 `json:"qr_attachment_id"` - VerifyStatus string `json:"verify_status"` - IsDefault bool `json:"is_default"` - CreatedAt string `json:"created_at,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` + ID int64 `json:"id"` + AccountType string `json:"account_type"` + BankCode string `json:"bank_code"` + BankName string `json:"bank_name"` + HolderMask string `json:"holder_mask"` + IdentityNoMask string `json:"identity_no_mask"` + AccountNoMask string `json:"account_no_mask"` + WechatQRAttachmentID int64 `json:"wechat_qr_attachment_id"` + AlipayQRAttachmentID int64 `json:"alipay_qr_attachment_id"` + VerifyStatus string `json:"verify_status"` + IsDefault bool `json:"is_default"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` } type QuoteInput struct { @@ -235,10 +232,18 @@ type WalletLedgerPage struct { func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service { service := &Service{repository: repository, redis: redisClient, now: time.Now} - encryptionKey, encryptionErr := decodeKey(cfg.DataEncryptionKey) - lookupKey, lookupErr := decodeKey(cfg.LookupHMACKey) - if encryptionErr != nil || len(encryptionKey) != 32 || lookupErr != nil || len(lookupKey) < 32 { - return service + masterKey := strings.TrimSpace(cfg.FinanceMasterKey) + var encryptionKey, lookupKey []byte + if len([]byte(masterKey)) >= 32 { + encryptionKey = deriveKey(masterKey, "data-encryption") + lookupKey = deriveKey(masterKey, "lookup-hmac") + } else { + var encryptionErr, lookupErr error + encryptionKey, encryptionErr = decodeKey(cfg.DataEncryptionKey) + lookupKey, lookupErr = decodeKey(cfg.LookupHMACKey) + if encryptionErr != nil || len(encryptionKey) != 32 || lookupErr != nil || len(lookupKey) < 32 { + return service + } } block, err := aes.NewCipher(encryptionKey) if err != nil { @@ -254,6 +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,10 +380,12 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64 WithdrawalNo: withdrawalNo, PaymentAccountID: account.ID, ExpectedAccountType: account.AccountType, ExpectedBankCode: account.BankCode, ExpectedBankName: account.BankName, ExpectedHolderCipher: account.HolderCipher, - ExpectedMobileCipher: account.MobileCipher, ExpectedAccountNoCipher: account.AccountNoCipher, - ExpectedAccountNoHash: account.AccountNoHash, ExpectedQRAttachmentID: account.QRAttachmentID, - Channel: *channel, - RequestAmount: quote.RequestAmount, FeeAmount: quote.FeeAmount, ActualAmount: quote.ActualAmount, + ExpectedIdentityNoCipher: account.IdentityNoCipher, ExpectedIdentityNoHash: account.IdentityNoHash, + ExpectedAccountNoCipher: account.AccountNoCipher, ExpectedAccountNoHash: account.AccountNoHash, + ExpectedWechatQRAttachmentID: account.WechatQRAttachmentID, + ExpectedAlipayQRAttachmentID: account.AlipayQRAttachmentID, + Channel: *channel, + RequestAmount: quote.RequestAmount, FeeAmount: quote.FeeAmount, ActualAmount: quote.ActualAmount, Currency: quote.Currency, AccountSnapshotCipher: snapshotCipher, AccountMask: account.AccountNoMask, BankName: account.BankName, HolderMask: account.HolderMask, AvailableLedgerNo: deterministicNumber("LA", tenantID, memberID, idempotencyKey), @@ -695,7 +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,58 +732,27 @@ 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, - VerifyStatus: verifyStatusName(row.VerifyStatus), IsDefault: row.IsDefault == 1, + 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 { diff --git a/internal/finance/service_test.go b/internal/finance/service_test.go index 44e52f5..87681d5 100644 --- a/internal/finance/service_test.go +++ b/internal/finance/service_test.go @@ -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") + } +} diff --git a/internal/httpapi/accounts.go b/internal/httpapi/accounts.go index 2912540..cb76e86 100644 --- a/internal/httpapi/accounts.go +++ b/internal/httpapi/accounts.go @@ -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 { diff --git a/internal/httpapi/api.go b/internal/httpapi/api.go index be578d8..a2578c1 100644 --- a/internal/httpapi/api.go +++ b/internal/httpapi/api.go @@ -3,25 +3,27 @@ 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 { - auth *auth.Authenticator - accounts *accounts.Service - portal *portal.Service - tasks *tasks.Service - mall *mall.Service - finance *finance.Service + auth *auth.Authenticator + accounts *accounts.Service + portal *portal.Service + tasks *tasks.Service + mall *mall.Service + finance *finance.Service + attachments *attachments.Service } -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"}) } diff --git a/internal/httpapi/attachments.go b/internal/httpapi/attachments.go new file mode 100644 index 0000000..81e20d2 --- /dev/null +++ b/internal/httpapi/attachments.go @@ -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) +} diff --git a/internal/httpapi/finance.go b/internal/httpapi/finance.go index 083159f..cb2ec56 100644 --- a/internal/httpapi/finance.go +++ b/internal/httpapi/finance.go @@ -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) { diff --git a/internal/httpapi/manual_products.go b/internal/httpapi/manual_products.go index 8f43452..d7d1d90 100644 --- a/internal/httpapi/manual_products.go +++ b/internal/httpapi/manual_products.go @@ -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) { diff --git a/internal/httpapi/portal.go b/internal/httpapi/portal.go index 881d861..9e71b94 100644 --- a/internal/httpapi/portal.go +++ b/internal/httpapi/portal.go @@ -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 diff --git a/internal/httpapi/profile.go b/internal/httpapi/profile.go index 1ef6ae9..9e8b0b8 100644 --- a/internal/httpapi/profile.go +++ b/internal/httpapi/profile.go @@ -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 { diff --git a/internal/httpapi/tasks.go b/internal/httpapi/tasks.go index 2442323..28597cc 100644 --- a/internal/httpapi/tasks.go +++ b/internal/httpapi/tasks.go @@ -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 { diff --git a/internal/mall/service.go b/internal/mall/service.go index 4c80eec..4d00f33 100644 --- a/internal/mall/service.go +++ b/internal/mall/service.go @@ -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 ( diff --git a/internal/mall/service_test.go b/internal/mall/service_test.go index 0e6e80c..3df11e7 100644 --- a/internal/mall/service_test.go +++ b/internal/mall/service_test.go @@ -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 { diff --git a/internal/portal/service.go b/internal/portal/service.go index 9ec5c5f..c02ba94 100644 --- a/internal/portal/service.go +++ b/internal/portal/service.go @@ -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) { diff --git a/internal/portal/service_test.go b/internal/portal/service_test.go index e982a0d..afc4e1b 100644 --- a/internal/portal/service_test.go +++ b/internal/portal/service_test.go @@ -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) + } +} diff --git a/internal/store/accounts.go b/internal/store/accounts.go index e50b5bc..c5c1985 100644 --- a/internal/store/accounts.go +++ b/internal/store/accounts.go @@ -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 { diff --git a/internal/store/attachments.go b/internal/store/attachments.go new file mode 100644 index 0000000..1cb4d82 --- /dev/null +++ b/internal/store/attachments.go @@ -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 +} diff --git a/internal/store/mysql.go b/internal/store/mysql.go index 0ff02e9..8545e65 100644 --- a/internal/store/mysql.go +++ b/internal/store/mysql.go @@ -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" ) diff --git a/internal/store/portal.go b/internal/store/portal.go index a2de4bd..62a2d9c 100644 --- a/internal/store/portal.go +++ b/internal/store/portal.go @@ -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") diff --git a/internal/store/profile.go b/internal/store/profile.go index 2e16131..44ff8f7 100644 --- a/internal/store/profile.go +++ b/internal/store/profile.go @@ -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 diff --git a/internal/store/recharges.go b/internal/store/recharges.go index dc92409..009fcc1 100644 --- a/internal/store/recharges.go +++ b/internal/store/recharges.go @@ -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) { diff --git a/internal/store/tasks.go b/internal/store/tasks.go index 37e6575..4502b3f 100644 --- a/internal/store/tasks.go +++ b/internal/store/tasks.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "time" "github.com/jmoiron/sqlx" @@ -104,12 +105,15 @@ type TaskStats struct { } type TaskSubmissionInput struct { - TenantID int64 - MemberID int64 - OrderID int64 - SubmissionNo string - EvidenceText string - AttachmentIDs []int64 + TenantID int64 + MemberID int64 + OrderID int64 + SubmissionNo string + EvidenceText string + AttachmentIDs []int64 + LedgerNo string + IdempotencyKey string + SettledAt time.Time } type TaskSubmissionResult struct { @@ -159,9 +163,10 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder defer tx.Rollback() var member struct { - LevelID int64 `db:"level_id"` + LevelID int64 `db:"level_id"` + AutoDispatch int `db:"auto_dispatch"` } - err = tx.GetContext(ctx, &member, `SELECT level_id FROM hg_app_member + err = tx.GetContext(ctx, &member, `SELECT level_id,auto_dispatch FROM hg_app_member WHERE tenant_id=? AND id=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID) if errors.Is(err, sql.ErrNoRows) { return nil, ErrAccountNotFound @@ -227,34 +232,16 @@ 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() AND om.created_at 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 diff --git a/internal/store/withdrawals.go b/internal/store/withdrawals.go index a01ac5a..6dfa5a4 100644 --- a/internal/store/withdrawals.go +++ b/internal/store/withdrawals.go @@ -10,39 +10,45 @@ import ( ) type PaymentAccount struct { - ID int64 `db:"id"` - AccountType int `db:"account_type"` - BankCode string `db:"bank_code"` - BankName string `db:"bank_name"` - HolderCipher string `db:"holder_cipher"` - HolderMask string `db:"holder_mask"` - MobileCipher string `db:"mobile_cipher"` - MobileMask string `db:"mobile_mask"` - AccountNoCipher string `db:"account_no_cipher"` - AccountNoHash string `db:"account_no_hash"` - AccountNoMask string `db:"account_no_mask"` - QRAttachmentID int64 `db:"qr_attachment_id"` - VerifyStatus int `db:"verify_status"` - IsDefault int `db:"is_default"` - CreatedAt string `db:"created_at"` - UpdatedAt string `db:"updated_at"` + ID int64 `db:"id"` + AccountType int `db:"account_type"` + BankCode string `db:"bank_code"` + BankName string `db:"bank_name"` + HolderCipher string `db:"holder_cipher"` + HolderMask string `db:"holder_mask"` + IdentityNoCipher string `db:"identity_no_cipher"` + IdentityNoHash string `db:"identity_no_hash"` + IdentityNoMask string `db:"identity_no_mask"` + MobileCipher string `db:"mobile_cipher"` + MobileMask string `db:"mobile_mask"` + AccountNoCipher string `db:"account_no_cipher"` + AccountNoHash string `db:"account_no_hash"` + AccountNoMask string `db:"account_no_mask"` + 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"` + UpdatedAt string `db:"updated_at"` } type PaymentAccountCreation struct { - TenantID int64 - MemberID int64 - ExpectedTradePasswordHash string - AccountType int - BankCode string - BankName string - HolderCipher string - HolderMask string - MobileCipher string - MobileMask string - AccountNoCipher string - AccountNoHash string - AccountNoMask string - QRAttachmentID int64 + TenantID int64 + MemberID int64 + AccountType int + BankCode string + BankName string + HolderCipher string + HolderMask string + IdentityNoCipher string + IdentityNoHash string + IdentityNoMask string + AccountNoCipher string + AccountNoHash string + AccountNoMask string + WechatQRAttachmentID int64 + AlipayQRAttachmentID int64 } type WithdrawalChannel struct { @@ -67,30 +73,32 @@ type WithdrawalWallet struct { } type WithdrawalCreation struct { - TenantID int64 - MemberID int64 - ExpectedTradePasswordHash string - WithdrawalNo string - PaymentAccountID int64 - ExpectedAccountType int - ExpectedBankCode string - ExpectedBankName string - ExpectedHolderCipher string - ExpectedMobileCipher string - ExpectedAccountNoCipher string - ExpectedAccountNoHash string - ExpectedQRAttachmentID int64 - Channel WithdrawalChannel - RequestAmount int64 - FeeAmount int64 - ActualAmount int64 - Currency string - AccountSnapshotCipher string - AccountMask string - BankName string - HolderMask string - AvailableLedgerNo string - FrozenLedgerNo string + TenantID int64 + MemberID int64 + ExpectedTradePasswordHash string + WithdrawalNo string + PaymentAccountID int64 + ExpectedAccountType int + ExpectedBankCode string + ExpectedBankName string + ExpectedHolderCipher string + ExpectedIdentityNoCipher string + ExpectedIdentityNoHash string + ExpectedAccountNoCipher string + ExpectedAccountNoHash string + ExpectedWechatQRAttachmentID int64 + ExpectedAlipayQRAttachmentID int64 + Channel WithdrawalChannel + RequestAmount int64 + FeeAmount int64 + ActualAmount int64 + Currency string + AccountSnapshotCipher string + AccountMask string + BankName string + HolderMask string + AvailableLedgerNo string + FrozenLedgerNo string } type WithdrawalOrder struct { @@ -126,8 +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,25 +153,22 @@ func (s *Store) CreatePaymentAccount(ctx context.Context, input PaymentAccountCr defer tx.Rollback() var memberID int64 err = tx.GetContext(ctx, &memberID, `SELECT id FROM hg_app_member WHERE tenant_id=? AND id=? - AND trade_password_hash=? AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, - input.TenantID, input.MemberID, input.ExpectedTradePasswordHash) + AND member_status=1 AND deleted_at IS NULL LIMIT 1 FOR UPDATE`, input.TenantID, input.MemberID) if errors.Is(err, sql.ErrNoRows) { return nil, ErrConcurrentUpdate } if err != nil { return nil, err } - if input.QRAttachmentID > 0 { - var attachmentCount int - err = tx.GetContext(ctx, &attachmentCount, `SELECT COUNT(*) FROM hg_sys_attachment - WHERE tenant_id=? AND id=? AND app_id='workbench' AND member_id=? AND status=1 AND mime_type LIKE 'image/%'`, - input.TenantID, input.QRAttachmentID, input.MemberID) - if err != nil { - return nil, err - } - if attachmentCount != 1 { - return nil, ErrAttachmentInvalid - } + var attachmentCount int + err = tx.GetContext(ctx, &attachmentCount, `SELECT COUNT(*) FROM hg_sys_attachment + WHERE id IN (?,?) AND app_id='olons_server' AND member_id=? AND status=1 AND mime_type LIKE 'image/%'`, + input.WechatQRAttachmentID, input.AlipayQRAttachmentID, input.MemberID) + if err != nil { + return nil, err + } + if attachmentCount != 2 { + return nil, ErrAttachmentInvalid } var accountCount int if err = tx.GetContext(ctx, &accountCount, `SELECT COUNT(*) FROM hg_app_member_payment_account @@ -173,12 +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 diff --git a/internal/tasks/service.go b/internal/tasks/service.go index bab4ce7..9d300b3 100644 --- a/internal/tasks/service.go +++ b/internal/tasks/service.go @@ -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 } diff --git a/internal/tasks/service_test.go b/internal/tasks/service_test.go index 9f94eb5..44bd70f 100644 --- a/internal/tasks/service_test.go +++ b/internal/tasks/service_test.go @@ -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") diff --git a/web/src/mobile/api.ts b/web/src/mobile/api.ts index a815d22..1986438 100644 --- a/web/src/mobile/api.ts +++ b/web/src/mobile/api.ts @@ -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 = { AUTH_REQUIRED: '登录已失效,请重新登录', @@ -19,6 +20,9 @@ const reasonMessages: Record = { 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)}`; } diff --git a/web/src/mobile/assets/announcement-popup@2x.png b/web/src/mobile/assets/announcement-popup@2x.png new file mode 100644 index 0000000..a80e474 Binary files /dev/null and b/web/src/mobile/assets/announcement-popup@2x.png differ diff --git a/web/src/mobile/components/AnnouncementPopup.vue b/web/src/mobile/components/AnnouncementPopup.vue new file mode 100644 index 0000000..32c2307 --- /dev/null +++ b/web/src/mobile/components/AnnouncementPopup.vue @@ -0,0 +1,148 @@ + + + + + + + diff --git a/web/src/mobile/styles.css b/web/src/mobile/styles.css index 16a69ad..ee67b28 100644 --- a/web/src/mobile/styles.css +++ b/web/src/mobile/styles.css @@ -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; } diff --git a/web/src/mobile/views/ClaimView.vue b/web/src/mobile/views/ClaimView.vue index b28ba2e..d976f84 100644 --- a/web/src/mobile/views/ClaimView.vue +++ b/web/src/mobile/views/ClaimView.vue @@ -5,7 +5,7 @@ 每日参与任务,赚取更多佣金 @@ -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([]); const stats = ref(); const eligibility = ref(); + const eligibilityByHall = ref>({}); const order = ref(); const showResult = ref(false); const resultState = ref('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({ 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(); - order.value = await request({ - url: '/v1/task-orders/claim', - method: 'POST', - data: { hall_id: halls.value[0].id }, - }); - handleOrder(order.value); + let stockError: unknown; + for (const hall of candidates) { + eligibility.value = eligibilityByHall.value[hall.id]; + try { + order.value = await request({ + url: '/v1/task-orders/claim', + method: 'POST', + data: { hall_id: hall.id }, + }); + handleOrder(order.value); + return; + } catch (err) { + if (err instanceof MobileApiError && err.reason === 'NO_TASK_AVAILABLE') { + stockError = err; + continue; + } + throw err; + } + } + throw stockError || new Error('当前暂无可领取任务'); } catch (err) { failureMessage.value = errorText(err); resultState.value = 'failed'; diff --git a/web/src/mobile/views/FormView.vue b/web/src/mobile/views/FormView.vue index e701886..3e82b9e 100644 --- a/web/src/mobile/views/FormView.vue +++ b/web/src/mobile/views/FormView.vue @@ -43,38 +43,38 @@ -
+ + +
- - - -

微信、支付宝二维码上传能力将在上传接口开放后提供。

+
+

微信二维码

+ +
+
+

支付宝二维码

+ +

{{ error }}