From dd729cb679a5ad91e5cee5006df280b8e4e11d29 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Mon, 12 Oct 2026 23:26:36 +0800 Subject: [PATCH] =?UTF-8?q?=E5=95=86=E5=9F=8Ec=E7=AB=AF=E5=89=8D=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/移动端任务与账户平台前后端对接文档.md | 41 +++- internal/finance/errors.go | 30 +-- internal/finance/service.go | 257 +++++++++++++++++++++ internal/finance/service_test.go | 62 ++++- internal/httpapi/api.go | 5 + internal/httpapi/finance.go | 101 ++++++++ internal/httpapi/portal.go | 40 ++++ internal/mall/service.go | 6 +- internal/mall/service_test.go | 6 +- internal/portal/service.go | 90 ++++++++ internal/portal/service_test.go | 50 +++- internal/store/errors.go | 4 + internal/store/manual_products.go | 4 + internal/store/portal.go | 47 ++++ internal/store/tasks.go | 12 +- internal/tasks/service_test.go | 6 +- web/src/router/index.ts | 24 ++ 17 files changed, 744 insertions(+), 41 deletions(-) diff --git a/docs/移动端任务与账户平台前后端对接文档.md b/docs/移动端任务与账户平台前后端对接文档.md index 3410eb8..a0e72a3 100644 --- a/docs/移动端任务与账户平台前后端对接文档.md +++ b/docs/移动端任务与账户平台前后端对接文档.md @@ -475,7 +475,33 @@ GET /v1/member-levels } ``` -### 6.5 首页和内容错误 +### 6.5 荣誉榜(已实现) + +```http +GET /v1/leaderboard +``` + +接口要求登录,返回当前租户内已完成任务数量排名前 50 的有效会员。排名只统计 +`hg_trade_order.order_type=2 AND order_status=4` 的真实任务订单;昵称由服务端脱敏,前端不得还原或展示手机号。 + +```json +{ + "code": 0, + "data": [ + { + "rank": 1, + "username": "风***淡", + "avatar_url": "https://cdn.example.com/avatar.png", + "completed_count": 12580, + "status": "领取成功" + } + ] +} +``` + +没有已完成任务时返回空数组,不使用模拟排行数据填充页面。 + +### 6.6 首页和内容错误 | HTTP | `reason` | 说明 | | --- | --- | --- | @@ -555,7 +581,7 @@ POST /v1/task-orders/claim 成功返回订单摘要,包括 `id`、`order_no`、`status`、`product`、`total_amount`、`commission_amount`、`currency` 和 `deadline_at`。 -领单事务会锁定当前会员和钱包记录,重新校验大厅等级、账户状态、最低余额、当日次数、未完成订单、任务发布与有效期、任务每日上限及单会员上限。成功后只创建 `status=matching` 的任务订单,不扣款、不冻结余额,也不生成钱包流水。当前表结构没有任务与商品的直接绑定字段,因此匹配完成前 `product.id`、`total_amount` 和 `unit_price_amount` 允许为 `0`;后续匹配流程补齐商品和金额并把订单置为 `pending` 后,客户端才能提交凭证。 +领单事务会锁定当前会员和钱包记录,重新校验大厅等级、账户状态、最低余额、当日次数、未完成订单、任务发布与有效期、任务每日上限及单会员上限。当前版本在事务内直接选中可用任务,创建 `status=pending` 的订单并记录 `matched_at`,客户端收到成功响应后即可进入订单详情并提交凭证;不依赖额外匹配进程。领取不扣款、不冻结余额,也不生成钱包流水。当前表结构没有任务与商品的直接绑定字段,因此 `product.id`、`total_amount` 和 `unit_price_amount` 允许为 `0`,任务名称和封面取自 `hg_task`。 | HTTP | `reason` | 说明 | | --- | --- | --- | @@ -708,6 +734,9 @@ GET /v1/manual-products?page=1&page_size=20&category_id=0 "summary": "商品说明摘要", "claimed_count": 1, "is_recommended": true, + "is_limited": true, + "start_at": "2026-10-01T00:00:00Z", + "end_at": "2026-10-31T23:59:59Z", "available_actions": ["contact_support"] } ], @@ -717,7 +746,7 @@ GET /v1/manual-products?page=1&page_size=20&category_id=0 } ``` -金额单位为分。现有 `hg_product` 没有独立摘要字段,`summary` 由商品详情转换为纯文本后截取前 120 个字符。`claimed_count` 统计该商品 `order_type=3` 且状态不是已取消或失败的订单。 +金额单位为分。现有 `hg_product` 没有独立摘要字段,`summary` 由商品详情转换为纯文本后截取前 120 个字符。`claimed_count` 统计该商品 `order_type=3` 且状态不是已取消或失败的订单。`is_limited` 在商品配置了 `end_at` 时为 `true`,客户端使用该字段区分限时商品,不根据分类名称推断;`start_at`、`end_at` 为商品展示有效期。 ### 8.2 商品详情 @@ -989,9 +1018,9 @@ Content-Type: application/json | 429 | `RATE_LIMITED` | 交易密码连续错误次数过多,15 分钟后重试 | | 503 | `WITHDRAWAL_CHANNEL_UNAVAILABLE` | 没有可用的取出通道 | -## 12. 客服、通知和上传(待实现) +## 12. 客服、通知和上传 -### 12.1 客服 +### 12.1 客服(已实现) ```http GET /v1/customer-services @@ -999,6 +1028,8 @@ GET /v1/customer-services 响应字段包括 `id`、`service_code`、`name`、`avatar_url`、`work_time`、`online_status`、`service_url`、`backup_contact` 和 `privacy_notice`。 +只返回 `hg_customer_service.status=1` 且未删除的客服,按 `sort`、`id` 排序。`online_status` 映射为 `online`、`offline` 或 `busy`。头像和客服地址仅返回不含用户凭据的 HTTPS URL,不符合要求时返回空字符串。 + 客服 URL 必须经服务端域名白名单校验。前端 WebView/iframe 只开启必要权限,并显示第三方服务来源和隐私提示。 ### 12.2 通知 diff --git a/internal/finance/errors.go b/internal/finance/errors.go index 8619c55..bc95598 100644 --- a/internal/finance/errors.go +++ b/internal/finance/errors.go @@ -3,17 +3,21 @@ package finance import "errors" var ( - ErrUnavailable = errors.New("finance service unavailable") - ErrAccountTypeInvalid = errors.New("payment account type invalid") - ErrAccountFieldsInvalid = errors.New("payment account fields invalid") - ErrTradePassword = errors.New("trade password invalid") - ErrTradePasswordUnset = errors.New("trade password is not set") - ErrRateLimited = errors.New("trade password attempts rate limited") - ErrCurrencyInvalid = errors.New("currency invalid") - ErrAmountInvalid = errors.New("amount invalid") - ErrIdempotencyKey = errors.New("idempotency key invalid") - ErrPageInvalid = errors.New("page invalid") - ErrPageSizeInvalid = errors.New("page size invalid") - ErrStatusInvalid = errors.New("withdrawal status invalid") - ErrOrderIDInvalid = errors.New("withdrawal order id invalid") + ErrUnavailable = errors.New("finance service unavailable") + ErrAccountTypeInvalid = errors.New("payment account type invalid") + ErrAccountFieldsInvalid = errors.New("payment account fields invalid") + ErrTradePassword = errors.New("trade password invalid") + ErrTradePasswordUnset = errors.New("trade password is not set") + ErrRateLimited = errors.New("trade password attempts rate limited") + ErrCurrencyInvalid = errors.New("currency invalid") + ErrAmountInvalid = errors.New("amount invalid") + ErrIdempotencyKey = errors.New("idempotency key invalid") + ErrPageInvalid = errors.New("page invalid") + ErrPageSizeInvalid = errors.New("page size invalid") + ErrStatusInvalid = errors.New("withdrawal status invalid") + ErrOrderIDInvalid = errors.New("withdrawal order id invalid") + ErrRechargeStatusInvalid = errors.New("recharge status invalid") + ErrRechargeOrderIDInvalid = errors.New("recharge order id invalid") + ErrRemarkInvalid = errors.New("remark invalid") + ErrVoucherInvalid = errors.New("voucher attachment ids invalid") ) diff --git a/internal/finance/service.go b/internal/finance/service.go index fdd2d0b..a8fc943 100644 --- a/internal/finance/service.go +++ b/internal/finance/service.go @@ -51,6 +51,10 @@ type Repository interface { CreateWithdrawal(context.Context, store.WithdrawalCreation) (*store.WithdrawalOrder, error) WithdrawalOrders(context.Context, int64, int64, int, int, int) (*store.WithdrawalPage, error) WithdrawalOrder(context.Context, int64, int64, int64) (*store.WithdrawalOrder, error) + RechargeChannels(context.Context, int64) ([]store.RechargeChannel, error) + CreateRecharge(context.Context, store.RechargeCreation) (*store.RechargeOrder, error) + RechargeOrders(context.Context, int64, int64, int, int, int) (*store.RechargePage, error) + RechargeOrder(context.Context, int64, int64, int64) (*store.RechargeOrder, error) } type Service struct { @@ -153,6 +157,58 @@ type WithdrawalPage struct { Total int64 `json:"total"` } +type RechargeChannel struct { + ID int64 `json:"id"` + Code string `json:"channel_code"` + Name string `json:"name"` + ChannelType int `json:"channel_type"` + MinimumAmount int64 `json:"minimum_amount"` + MaximumAmount int64 `json:"maximum_amount"` + FeeType int `json:"fee_type"` + FeeFixedAmount int64 `json:"fee_fixed_amount"` + FeeRate int `json:"fee_rate"` +} + +type CreateRechargeInput struct { + ChannelID int64 `json:"channel_id"` + Amount int64 `json:"amount"` + Currency string `json:"currency"` + VoucherAttachmentIDs []int64 `json:"voucher_attachment_ids"` + Remark string `json:"remark"` +} + +type Recharge struct { + ID int64 `json:"id"` + RechargeNo string `json:"recharge_no"` + ChannelID int64 `json:"channel_id"` + ChannelName string `json:"channel_name"` + RequestAmount int64 `json:"request_amount"` + PayableAmount int64 `json:"payable_amount"` + PaidAmount int64 `json:"paid_amount"` + Currency string `json:"currency"` + VoucherAttachmentIDs json.RawMessage `json:"voucher_attachment_ids"` + Status string `json:"status"` + StatusText string `json:"status_text"` + ReasonCode string `json:"reason_code"` + ReasonText string `json:"reason_text"` + Remark string `json:"remark"` + PaymentInstructions string `json:"payment_instructions"` + ExpireAt string `json:"expire_at,omitempty"` + PaidAt string `json:"paid_at,omitempty"` + ReviewedAt string `json:"reviewed_at,omitempty"` + CreditedAt string `json:"credited_at,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type RechargePage struct { + Items []Recharge `json:"items"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int64 `json:"total"` + HasNext bool `json:"has_next"` +} + func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service { service := &Service{repository: repository, redis: redisClient, now: time.Now} encryptionKey, encryptionErr := decodeKey(cfg.DataEncryptionKey) @@ -357,6 +413,134 @@ func (s *Service) WithdrawalOrder(ctx context.Context, tenantID, memberID, order return &result, nil } +func (s *Service) RechargeChannels(ctx context.Context, tenantID int64) ([]RechargeChannel, error) { + if !s.ready { + return nil, ErrUnavailable + } + rows, err := s.repository.RechargeChannels(ctx, tenantID) + if err != nil { + return nil, err + } + items := make([]RechargeChannel, 0, len(rows)) + for _, row := range rows { + items = append(items, RechargeChannel{ID: row.ID, Code: row.Code, Name: row.Name, + ChannelType: row.ChannelType, MinimumAmount: greaterInt64(1, row.MinAmount), + MaximumAmount: row.MaxAmount, FeeType: row.FeeType, + FeeFixedAmount: row.FeeFixedAmount, FeeRate: row.FeeRate}) + } + return items, nil +} + +func (s *Service) CreateRecharge(ctx context.Context, tenantID, memberID int64, input CreateRechargeInput) (*Recharge, error) { + if !s.ready { + return nil, ErrUnavailable + } + input.Currency = strings.ToUpper(strings.TrimSpace(input.Currency)) + input.Remark = strings.TrimSpace(input.Remark) + if input.Currency != "CNY" { + return nil, ErrCurrencyInvalid + } + if input.ChannelID <= 0 { + return nil, store.ErrRechargeChannel + } + if input.Amount <= 0 { + return nil, store.ErrRechargeAmount + } + if utf8.RuneCountInString(input.Remark) > 500 { + return nil, ErrRemarkInvalid + } + if len(input.VoucherAttachmentIDs) > 9 { + return nil, ErrVoucherInvalid + } + seen := make(map[int64]struct{}, len(input.VoucherAttachmentIDs)) + for _, id := range input.VoucherAttachmentIDs { + if id <= 0 { + return nil, ErrVoucherInvalid + } + if _, exists := seen[id]; exists { + return nil, ErrVoucherInvalid + } + seen[id] = struct{}{} + } + channels, err := s.repository.RechargeChannels(ctx, tenantID) + if err != nil { + return nil, err + } + var channel *store.RechargeChannel + for i := range channels { + if channels[i].ID == input.ChannelID { + channel = &channels[i] + break + } + } + if channel == nil { + return nil, store.ErrRechargeChannel + } + if input.Amount < greaterInt64(1, channel.MinAmount) || (channel.MaxAmount > 0 && input.Amount > channel.MaxAmount) { + return nil, store.ErrRechargeAmount + } + orderNo, err := randomNumber("RC") + if err != nil { + return nil, err + } + row, err := s.repository.CreateRecharge(ctx, store.RechargeCreation{TenantID: tenantID, + MemberID: memberID, RechargeNo: orderNo, ChannelID: input.ChannelID, + RequestAmount: input.Amount, Currency: input.Currency, + VoucherAttachmentIDs: input.VoucherAttachmentIDs, Remark: input.Remark}) + if err != nil { + return nil, err + } + result := rechargeFromStore(*row) + return &result, nil +} + +func (s *Service) RechargeOrders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*RechargePage, error) { + if !s.ready { + return nil, ErrUnavailable + } + if page == 0 { + page = 1 + } + if pageSize == 0 { + pageSize = defaultPageSize + } + if page < 1 { + return nil, ErrPageInvalid + } + if pageSize < 1 || pageSize > maxPageSize { + return nil, ErrPageSizeInvalid + } + statusCode, ok := rechargeStatusCode(status) + if !ok { + return nil, ErrRechargeStatusInvalid + } + rows, err := s.repository.RechargeOrders(ctx, tenantID, memberID, statusCode, (page-1)*pageSize, pageSize) + if err != nil { + return nil, err + } + items := make([]Recharge, 0, len(rows.Items)) + for _, row := range rows.Items { + items = append(items, rechargeFromStore(row)) + } + return &RechargePage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total, + HasNext: int64(page*pageSize) < rows.Total}, nil +} + +func (s *Service) RechargeOrder(ctx context.Context, tenantID, memberID, orderID int64) (*Recharge, error) { + if !s.ready { + return nil, ErrUnavailable + } + if orderID <= 0 { + return nil, ErrRechargeOrderIDInvalid + } + row, err := s.repository.RechargeOrder(ctx, tenantID, memberID, orderID) + if err != nil { + return nil, err + } + result := rechargeFromStore(*row) + return &result, nil +} + func (s *Service) quote(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, error) { quote, _, _, err := s.quoteData(ctx, tenantID, memberID, input) return quote, err @@ -537,6 +721,64 @@ func withdrawalFromStore(row store.WithdrawalOrder) Withdrawal { IdempotentReplay: row.Replayed} } +func rechargeFromStore(row store.RechargeOrder) Recharge { + vouchers := row.VoucherAttachmentIDs + if len(vouchers) == 0 { + vouchers = json.RawMessage(`[]`) + } + result := Recharge{ID: row.ID, RechargeNo: row.RechargeNo, ChannelID: row.ChannelID, + ChannelName: row.ChannelName, RequestAmount: row.RequestAmount, PayableAmount: row.RequestAmount, + PaidAmount: row.PaidAmount, Currency: row.Currency, VoucherAttachmentIDs: vouchers, + Status: rechargeStatusName(row.Status), StatusText: rechargeStatusText(row.Status), + ReasonCode: row.ReviewReasonCode, ReasonText: row.ReviewReason, Remark: row.Remark, + PaymentInstructions: "沙箱订单,请按页面提示完成后续操作,不要向个人账户转账。", + PaidAt: row.PaidAt, ReviewedAt: row.ReviewedAt, CreditedAt: row.CreditedAt, + CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt} + if row.Status == 1 && row.CreatedAt != "" { + if created, err := time.Parse(time.RFC3339, row.CreatedAt); err == nil { + result.ExpireAt = created.Add(30 * time.Minute).Format(time.RFC3339) + } + } + return result +} + +func rechargeStatusCode(value string) (int, bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "all": + return 0, true + case "pending_payment": + return 1, true + case "reviewing", "processing": + return 2, true + case "succeeded": + return 3, true + case "failed": + return 4, true + case "expired": + return 5, true + case "cancelled": + return 6, true + default: + return 0, false + } +} + +func rechargeStatusName(value int) string { + names := map[int]string{1: "pending_payment", 2: "reviewing", 3: "succeeded", 4: "failed", 5: "expired", 6: "cancelled"} + if name := names[value]; name != "" { + return name + } + return "unknown" +} + +func rechargeStatusText(value int) string { + names := map[int]string{1: "待支付", 2: "审核中", 3: "已成功", 4: "失败", 5: "已过期", 6: "已取消"} + if name := names[value]; name != "" { + return name + } + return "未知状态" +} + func withdrawalFee(amount int64, channel store.WithdrawalChannel) (int64, error) { if amount <= 0 || channel.FeeRate < 0 || channel.FeeRate > 10000 || channel.FeeFixedAmount < 0 { return 0, store.ErrWithdrawalChannel @@ -650,6 +892,21 @@ func deterministicNumber(prefix string, tenantID, memberID int64, key string) st return prefix + strings.ToUpper(hex.EncodeToString(digest[:16])) } +func randomNumber(prefix string) (string, error) { + random := make([]byte, 12) + if _, err := rand.Read(random); err != nil { + return "", err + } + return prefix + time.Now().UTC().Format("20060102150405") + strings.ToUpper(hex.EncodeToString(random)), nil +} + +func greaterInt64(left, right int64) int64 { + if left > right { + return left + } + return right +} + func decodeKey(value string) ([]byte, error) { value = strings.TrimSpace(value) if decoded, err := base64.StdEncoding.DecodeString(value); err == nil { diff --git a/internal/finance/service_test.go b/internal/finance/service_test.go index 08817e2..11a90f8 100644 --- a/internal/finance/service_test.go +++ b/internal/finance/service_test.go @@ -12,11 +12,14 @@ import ( ) type fakeRepository struct { - config json.RawMessage - account *store.PaymentAccount - channel *store.WithdrawalChannel - wallet *store.WithdrawalWallet - orders []store.WithdrawalOrder + config json.RawMessage + account *store.PaymentAccount + channel *store.WithdrawalChannel + wallet *store.WithdrawalWallet + orders []store.WithdrawalOrder + rechargeChannels []store.RechargeChannel + rechargeOrders []store.RechargeOrder + createdRecharge store.RechargeCreation } func (f *fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) { @@ -49,6 +52,21 @@ func (f *fakeRepository) WithdrawalOrders(context.Context, int64, int64, int, in func (f *fakeRepository) WithdrawalOrder(context.Context, int64, int64, int64) (*store.WithdrawalOrder, error) { return nil, errors.New("not implemented") } +func (f *fakeRepository) RechargeChannels(context.Context, int64) ([]store.RechargeChannel, error) { + return f.rechargeChannels, nil +} +func (f *fakeRepository) CreateRecharge(_ context.Context, input store.RechargeCreation) (*store.RechargeOrder, error) { + f.createdRecharge = input + return &store.RechargeOrder{ID: 8, RechargeNo: input.RechargeNo, ChannelID: input.ChannelID, + ChannelName: "人工审核", RequestAmount: input.RequestAmount, Currency: input.Currency, + Status: 1, Remark: input.Remark}, nil +} +func (f *fakeRepository) RechargeOrders(context.Context, int64, int64, int, int, int) (*store.RechargePage, error) { + return &store.RechargePage{Items: f.rechargeOrders, Total: int64(len(f.rechargeOrders))}, nil +} +func (f *fakeRepository) RechargeOrder(context.Context, int64, int64, int64) (*store.RechargeOrder, error) { + return nil, errors.New("not implemented") +} func TestQuoteCalculatesRateFeeAndLimits(t *testing.T) { repository := &fakeRepository{config: json.RawMessage(`{"features":{"withdrawal_enabled":true}}`), @@ -105,6 +123,40 @@ func TestWithdrawalStatusValidation(t *testing.T) { } } +func TestCreateRechargeValidatesChannelAndMapsOrder(t *testing.T) { + repository := &fakeRepository{rechargeChannels: []store.RechargeChannel{{ + ID: 4, Code: "manual", Name: "人工审核", MinAmount: 100, MaxAmount: 100000, + }}} + service := &Service{repository: repository, ready: true, now: time.Now} + result, err := service.CreateRecharge(context.Background(), 0, 9, CreateRechargeInput{ + ChannelID: 4, Amount: 50000, Currency: "cny", Remark: "测试", + }) + if err != nil { + t.Fatal(err) + } + if result.Status != "pending_payment" || result.RequestAmount != 50000 || + repository.createdRecharge.MemberID != 9 || repository.createdRecharge.Currency != "CNY" { + t.Fatalf("unexpected recharge: %#v, creation: %#v", result, repository.createdRecharge) + } +} + +func TestRechargeOrdersMapsStatusAndPagination(t *testing.T) { + repository := &fakeRepository{rechargeOrders: []store.RechargeOrder{{ + ID: 3, RechargeNo: "RC3", RequestAmount: 20000, Currency: "CNY", Status: 3, + }}} + service := &Service{repository: repository, ready: true, now: time.Now} + result, err := service.RechargeOrders(context.Background(), 0, 9, 1, 20, "succeeded") + if err != nil { + t.Fatal(err) + } + if result.Total != 1 || len(result.Items) != 1 || result.Items[0].Status != "succeeded" || result.HasNext { + t.Fatalf("unexpected recharge page: %#v", result) + } + if _, err = service.RechargeOrders(context.Background(), 0, 9, 1, 20, "invalid"); !errors.Is(err, ErrRechargeStatusInvalid) { + t.Fatalf("expected recharge status error, got %v", err) + } +} + func TestDeterministicNumberIsStableAndScoped(t *testing.T) { first := deterministicNumber("WD", 0, 1, "request-key") if first != deterministicNumber("WD", 0, 1, "request-key") || first == deterministicNumber("WD", 0, 2, "request-key") { diff --git a/internal/httpapi/api.go b/internal/httpapi/api.go index 129eafe..fccf52e 100644 --- a/internal/httpapi/api.go +++ b/internal/httpapi/api.go @@ -36,6 +36,8 @@ func (a *API) Register(mux *http.ServeMux) { mux.HandleFunc("/v1/banners", a.banners) mux.HandleFunc("/v1/contents/", a.content) mux.HandleFunc("/v1/member-levels", a.memberLevels) + mux.HandleFunc("/v1/leaderboard", a.leaderboard) + mux.HandleFunc("/v1/customer-services", a.customerServices) mux.HandleFunc("/v1/task-halls", a.taskHalls) mux.HandleFunc("/v1/tasks/eligibility", a.taskEligibility) mux.HandleFunc("/v1/tasks/stats", a.taskStats) @@ -45,6 +47,9 @@ 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/recharge/channels", a.rechargeChannels) + mux.HandleFunc("/v1/recharge-orders", a.rechargeOrders) + mux.HandleFunc("/v1/recharge-orders/", a.rechargeOrder) mux.HandleFunc("/v1/withdrawal-orders/quote", a.withdrawalQuote) mux.HandleFunc("/v1/withdrawal-orders", a.withdrawalOrders) mux.HandleFunc("/v1/withdrawal-orders/", a.withdrawalOrder) diff --git a/internal/httpapi/finance.go b/internal/httpapi/finance.go index 15a29fe..7189999 100644 --- a/internal/httpapi/finance.go +++ b/internal/httpapi/finance.go @@ -62,6 +62,91 @@ func (a *API) withdrawalQuote(w http.ResponseWriter, r *http.Request) { writeSuccess(w, r, result) } +func (a *API) rechargeChannels(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r, http.MethodGet) + return + } + principal, ok := a.authenticateFinanceMember(w, r) + if !ok { + return + } + result, err := a.finance.RechargeChannels(r.Context(), principal.TenantID) + if err != nil { + writeFinanceError(w, r, err) + return + } + writeSuccess(w, r, result) +} + +func (a *API) rechargeOrders(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodPost { + methodNotAllowed(w, r, http.MethodGet, http.MethodPost) + return + } + principal, ok := a.authenticateFinanceMember(w, r) + if !ok { + return + } + if r.Method == http.MethodGet { + page, ok := optionalPositiveInt(w, r, "page") + if !ok { + writeFinanceError(w, r, finance.ErrPageInvalid) + return + } + pageSize, ok := optionalPositiveInt(w, r, "page_size") + if !ok { + writeFinanceError(w, r, finance.ErrPageSizeInvalid) + return + } + result, err := a.finance.RechargeOrders(r.Context(), principal.TenantID, principal.MemberID, + page, pageSize, r.URL.Query().Get("status")) + if err != nil { + writeFinanceError(w, r, err) + return + } + writeSuccess(w, r, result) + return + } + var body finance.CreateRechargeInput + if !decodeBody(w, r, &body) { + return + } + result, err := a.finance.CreateRecharge(r.Context(), principal.TenantID, principal.MemberID, body) + if err != nil { + writeFinanceError(w, r, err) + return + } + writeSuccess(w, r, result) +} + +func (a *API) rechargeOrder(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r, http.MethodGet) + return + } + orderIDText := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/recharge-orders/"), "/") + if orderIDText == "" || strings.Contains(orderIDText, "/") { + writeFinanceError(w, r, finance.ErrRechargeOrderIDInvalid) + return + } + orderID, err := strconv.ParseInt(orderIDText, 10, 64) + if err != nil || orderID <= 0 { + writeFinanceError(w, r, finance.ErrRechargeOrderIDInvalid) + return + } + principal, ok := a.authenticateFinanceMember(w, r) + if !ok { + return + } + result, err := a.finance.RechargeOrder(r.Context(), principal.TenantID, principal.MemberID, orderID) + if err != nil { + writeFinanceError(w, r, err) + return + } + writeSuccess(w, r, result) +} + func (a *API) withdrawalOrders(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodPost { methodNotAllowed(w, r, http.MethodGet, http.MethodPost) @@ -159,6 +244,12 @@ func writeFinanceError(w http.ResponseWriter, r *http.Request, err error) { writeError(w, r, http.StatusBadRequest, 30000, "CURRENCY_INVALID", "validation failed", map[string]any{"field": "currency"}) case errors.Is(err, finance.ErrAmountInvalid), errors.Is(err, store.ErrWithdrawalAmount): writeError(w, r, http.StatusUnprocessableEntity, 30022, "WITHDRAWAL_AMOUNT_INVALID", "withdrawal amount is outside the allowed range", nil) + case errors.Is(err, store.ErrRechargeAmount): + writeError(w, r, http.StatusUnprocessableEntity, 30022, "RECHARGE_AMOUNT_INVALID", "recharge amount is outside the allowed range", nil) + case errors.Is(err, finance.ErrRemarkInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "RECHARGE_REMARK_INVALID", "validation failed", map[string]any{"field": "remark"}) + case errors.Is(err, finance.ErrVoucherInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "RECHARGE_VOUCHER_INVALID", "validation failed", map[string]any{"field": "voucher_attachment_ids"}) case errors.Is(err, finance.ErrIdempotencyKey): writeError(w, r, http.StatusBadRequest, 30000, "IDEMPOTENCY_KEY_INVALID", "valid Idempotency-Key header is required", map[string]any{"field": "Idempotency-Key"}) case errors.Is(err, finance.ErrPageInvalid): @@ -167,6 +258,10 @@ func writeFinanceError(w http.ResponseWriter, r *http.Request, err error) { writeError(w, r, http.StatusBadRequest, 30000, "PAGE_SIZE_INVALID", "validation failed", map[string]any{"field": "page_size"}) case errors.Is(err, finance.ErrStatusInvalid): writeError(w, r, http.StatusBadRequest, 30000, "WITHDRAWAL_STATUS_INVALID", "validation failed", map[string]any{"field": "status"}) + case errors.Is(err, finance.ErrRechargeStatusInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "RECHARGE_STATUS_INVALID", "validation failed", map[string]any{"field": "status"}) + case errors.Is(err, finance.ErrRechargeOrderIDInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "RECHARGE_ORDER_ID_INVALID", "validation failed", map[string]any{"field": "order_id"}) case errors.Is(err, finance.ErrOrderIDInvalid): writeError(w, r, http.StatusBadRequest, 30000, "WITHDRAWAL_ORDER_ID_INVALID", "validation failed", map[string]any{"field": "order_id"}) case errors.Is(err, store.ErrPaymentAccountExists): @@ -177,6 +272,8 @@ func writeFinanceError(w http.ResponseWriter, r *http.Request, err error) { writeError(w, r, http.StatusUnprocessableEntity, 30022, "WITHDRAWAL_DISABLED", "withdrawal feature is disabled", nil) case errors.Is(err, store.ErrWithdrawalChannel): writeError(w, r, http.StatusServiceUnavailable, 30053, "WITHDRAWAL_CHANNEL_UNAVAILABLE", "withdrawal channel is unavailable", nil) + case errors.Is(err, store.ErrRechargeChannel): + writeError(w, r, http.StatusServiceUnavailable, 30053, "RECHARGE_CHANNEL_UNAVAILABLE", "recharge channel is unavailable", nil) case errors.Is(err, store.ErrWithdrawalLimit): writeError(w, r, http.StatusUnprocessableEntity, 30022, "WITHDRAWAL_DAILY_LIMIT_REACHED", "daily withdrawal limit reached", nil) case errors.Is(err, store.ErrInsufficientBalance): @@ -187,6 +284,10 @@ func writeFinanceError(w http.ResponseWriter, r *http.Request, err error) { writeError(w, r, http.StatusUnprocessableEntity, 30022, "ACCOUNT_RESTRICTED", "account is restricted", nil) case errors.Is(err, store.ErrWithdrawalNotFound): writeError(w, r, http.StatusNotFound, 30044, "WITHDRAWAL_ORDER_NOT_FOUND", "withdrawal order not found", nil) + case errors.Is(err, store.ErrRechargeNotFound): + writeError(w, r, http.StatusNotFound, 30044, "RECHARGE_ORDER_NOT_FOUND", "recharge order not found", nil) + case errors.Is(err, store.ErrRechargeVoucher): + writeError(w, r, http.StatusBadRequest, 30000, "RECHARGE_VOUCHER_INVALID", "validation failed", map[string]any{"field": "voucher_attachment_ids"}) case errors.Is(err, store.ErrIdempotencyConflict): writeError(w, r, http.StatusConflict, 30040, "IDEMPOTENCY_CONFLICT", "Idempotency-Key was reused with different request data", nil) case errors.Is(err, store.ErrAttachmentInvalid): diff --git a/internal/httpapi/portal.go b/internal/httpapi/portal.go index d014be0..a2d3b81 100644 --- a/internal/httpapi/portal.go +++ b/internal/httpapi/portal.go @@ -117,6 +117,46 @@ func (a *API) memberLevels(w http.ResponseWriter, r *http.Request) { writeSuccess(w, r, items) } +func (a *API) leaderboard(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r, http.MethodGet) + return + } + if !a.ensurePortal(w, r) { + return + } + principal, ok := a.authenticateMember(w, r) + if !ok { + return + } + items, err := a.portal.Leaderboard(r.Context(), principal.TenantID) + if err != nil { + writePortalError(w, r, err) + return + } + writeSuccess(w, r, items) +} + +func (a *API) customerServices(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r, http.MethodGet) + return + } + if !a.ensurePortal(w, r) { + return + } + principal, ok := a.authenticateMember(w, r) + if !ok { + return + } + items, err := a.portal.CustomerServices(r.Context(), principal.TenantID) + if err != nil { + writePortalError(w, r, err) + return + } + writeSuccess(w, r, items) +} + func (a *API) ensurePortal(w http.ResponseWriter, r *http.Request) bool { if a.portal != nil { return true diff --git a/internal/mall/service.go b/internal/mall/service.go index a5f9e40..4c80eec 100644 --- a/internal/mall/service.go +++ b/internal/mall/service.go @@ -44,6 +44,9 @@ type Product struct { Summary string `json:"summary"` ClaimedCount int64 `json:"claimed_count"` IsRecommended bool `json:"is_recommended"` + IsLimited bool `json:"is_limited"` + StartAt string `json:"start_at"` + EndAt string `json:"end_at"` AvailableActions []string `json:"available_actions"` PaymentType string `json:"payment_type,omitempty"` DetailText string `json:"detail_text,omitempty"` @@ -113,7 +116,8 @@ func productFromStore(row store.ManualProduct, detail bool) Product { Category: Category{ID: row.CategoryID, Code: row.CategoryCode, Name: row.CategoryName}, Name: row.Name, ShopName: row.ShopName, PriceAmount: row.PriceAmount, Currency: row.Currency, CoverURL: safeHTTPSURL(row.CoverURL), Summary: truncate(detailText, maxSummaryRunes), - ClaimedCount: row.ClaimedCount, IsRecommended: row.IsRecommended, + ClaimedCount: row.ClaimedCount, IsRecommended: row.IsRecommended, IsLimited: row.EndAt != "", + StartAt: row.StartAt, EndAt: row.EndAt, AvailableActions: []string{"contact_support"}} if detail { result.PaymentType = row.PaymentType diff --git a/internal/mall/service_test.go b/internal/mall/service_test.go index 9c2cc22..0e6e80c 100644 --- a/internal/mall/service_test.go +++ b/internal/mall/service_test.go @@ -25,14 +25,16 @@ func TestProductsMapsSafeCard(t *testing.T) { service := New(&fakeRepository{items: []store.ManualProduct{{ID: 4, ProductNo: "DEMO-P-03", CategoryID: 2, CategoryCode: "DAILY", CategoryName: "日用", Name: "体验商品", ShopName: "平台", PriceAmount: 12900, Currency: "CNY", CoverURL: "https://example.com/cover.png", - DetailHTML: "

标题

内容

", ClaimedCount: 2, IsRecommended: true}}}) + DetailHTML: "

标题

内容

", ClaimedCount: 2, IsRecommended: true, + StartAt: "2026-10-01T00:00:00Z", EndAt: "2026-10-31T00:00:00Z"}}}) result, err := service.Products(context.Background(), 0, 0, 1, 20) if err != nil { t.Fatal(err) } if len(result.Items) != 1 || result.Items[0].CoverURL == "" || result.Items[0].Summary != "标题 内容" || - len(result.Items[0].AvailableActions) != 1 || result.Items[0].AvailableActions[0] != "contact_support" { + !result.Items[0].IsLimited || result.Items[0].EndAt == "" || len(result.Items[0].AvailableActions) != 1 || + result.Items[0].AvailableActions[0] != "contact_support" { t.Fatalf("unexpected product: %#v", result.Items) } } diff --git a/internal/portal/service.go b/internal/portal/service.go index ebd8a73..8aff228 100644 --- a/internal/portal/service.go +++ b/internal/portal/service.go @@ -23,6 +23,8 @@ type Repository interface { ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error) ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error) MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error) + TaskLeaderboard(context.Context, int64, int) ([]store.LeaderboardMember, error) + ActiveCustomerServices(context.Context, int64) ([]store.CustomerService, error) } type Service struct { @@ -95,6 +97,26 @@ type MemberLevel struct { Version int `json:"version"` } +type LeaderboardEntry struct { + Rank int `json:"rank"` + Username string `json:"username"` + AvatarURL string `json:"avatar_url"` + CompletedCount int64 `json:"completed_count"` + Status string `json:"status"` +} + +type CustomerService struct { + ID int64 `json:"id"` + ServiceCode string `json:"service_code"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` + WorkTime string `json:"work_time"` + OnlineStatus string `json:"online_status"` + ServiceURL string `json:"service_url"` + BackupContact string `json:"backup_contact"` + PrivacyNotice string `json:"privacy_notice"` +} + func New(repository Repository) *Service { return &Service{repository: repository} } @@ -183,6 +205,51 @@ func (s *Service) MemberLevels(ctx context.Context, tenantID int64) ([]MemberLev return items, nil } +func (s *Service) Leaderboard(ctx context.Context, tenantID int64) ([]LeaderboardEntry, error) { + rows, err := s.repository.TaskLeaderboard(ctx, tenantID, 50) + if err != nil { + return nil, err + } + items := make([]LeaderboardEntry, 0, len(rows)) + for index, row := range rows { + items = append(items, LeaderboardEntry{Rank: index + 1, Username: maskedUsername(row.Username), + AvatarURL: row.AvatarURL, CompletedCount: row.CompletedCount, Status: "领取成功"}) + } + return items, nil +} + +func (s *Service) CustomerServices(ctx context.Context, tenantID int64) ([]CustomerService, error) { + rows, err := s.repository.ActiveCustomerServices(ctx, tenantID) + if err != nil { + return nil, err + } + items := make([]CustomerService, 0, len(rows)) + for _, row := range rows { + items = append(items, CustomerService{ + ID: row.ID, ServiceCode: row.ServiceCode, Name: row.Name, + AvatarURL: safeExternalURL(row.AvatarURL), WorkTime: row.WorkTime, + OnlineStatus: customerServiceStatus(row.OnlineStatus), ServiceURL: safeExternalURL(row.ServiceURL), + BackupContact: row.BackupContact, + PrivacyNotice: "请勿在客服会话中发送登录密码、支付密码或短信验证码", + }) + } + return items, nil +} + +func maskedUsername(value string) string { + runes := []rune(strings.TrimSpace(value)) + switch len(runes) { + case 0: + return "会员" + case 1: + return string(runes[0]) + "*" + case 2: + return string(runes[0]) + "*" + default: + return string(runes[0]) + "***" + string(runes[len(runes)-1]) + } +} + func parseFeatures(raw json.RawMessage) Features { var config map[string]any if json.Unmarshal(raw, &config) != nil { @@ -241,6 +308,29 @@ func bannerTarget(kind int, value string) (string, string) { return "none", "" } +func safeExternalURL(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + parsed, err := url.Parse(value) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil { + return "" + } + return parsed.String() +} + +func customerServiceStatus(status int) string { + switch status { + case 1: + return "online" + case 3: + return "busy" + default: + return "offline" + } +} + func noticeSourceName(source int) string { if source == 2 { return "operational_simulation" diff --git a/internal/portal/service_test.go b/internal/portal/service_test.go index 5065be7..8b8fc1f 100644 --- a/internal/portal/service_test.go +++ b/internal/portal/service_test.go @@ -13,12 +13,14 @@ import ( ) type fakeRepository struct { - config json.RawMessage - banners []store.Banner - content *store.Content - levels []store.MemberLevel - notices []store.MemberNotice - summary *store.TaskSummary + config json.RawMessage + banners []store.Banner + content *store.Content + levels []store.MemberLevel + notices []store.MemberNotice + summary *store.TaskSummary + leaders []store.LeaderboardMember + services []store.CustomerService } func (r fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) { @@ -42,6 +44,12 @@ func (r fakeRepository) ActiveMemberNotices(context.Context, int64, int64, int) func (r fakeRepository) MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error) { return r.summary, nil } +func (r fakeRepository) TaskLeaderboard(context.Context, int64, int) ([]store.LeaderboardMember, error) { + return r.leaders, nil +} +func (r fakeRepository) ActiveCustomerServices(context.Context, int64) ([]store.CustomerService, error) { + return r.services, nil +} func TestParseFeaturesSupportsNestedAndLegacyKeys(t *testing.T) { features := parseFeatures(json.RawMessage(`{ @@ -126,3 +134,33 @@ func TestHomeUsesSettledSummaryAndSnakeCaseJSON(t *testing.T) { } } } + +func TestLeaderboardMasksUsernameAndAddsRank(t *testing.T) { + service := New(fakeRepository{leaders: []store.LeaderboardMember{ + {MemberID: 7, Username: "风轻云淡", CompletedCount: 12}, + {MemberID: 8, Username: "李明", CompletedCount: 9}, + }}) + items, err := service.Leaderboard(context.Background(), 1) + if err != nil { + t.Fatal(err) + } + if len(items) != 2 || items[0].Rank != 1 || items[0].Username != "风***淡" || + items[1].Username != "李*" || items[1].Status != "领取成功" { + t.Fatalf("unexpected leaderboard: %+v", items) + } +} + +func TestCustomerServicesSanitizeURLsAndMapStatus(t *testing.T) { + service := New(fakeRepository{services: []store.CustomerService{ + {ID: 1, Name: "在线客服", AvatarURL: "https://example.com/avatar.png", ServiceURL: "https://example.com/chat", OnlineStatus: 1}, + {ID: 2, Name: "离线客服", AvatarURL: "javascript:alert(1)", ServiceURL: "http://example.com/chat", OnlineStatus: 2}, + }}) + items, err := service.CustomerServices(context.Background(), 0) + if err != nil { + t.Fatal(err) + } + if len(items) != 2 || items[0].OnlineStatus != "online" || items[0].ServiceURL == "" || + items[1].OnlineStatus != "offline" || items[1].AvatarURL != "" || items[1].ServiceURL != "" { + t.Fatalf("unexpected customer services: %+v", items) + } +} diff --git a/internal/store/errors.go b/internal/store/errors.go index 370d484..b9a3703 100644 --- a/internal/store/errors.go +++ b/internal/store/errors.go @@ -25,6 +25,10 @@ var ( ErrWithdrawalAmount = errors.New("withdrawal amount invalid") ErrWithdrawalLimit = errors.New("withdrawal daily limit reached") ErrWithdrawalNotFound = errors.New("withdrawal order not found") + ErrRechargeChannel = errors.New("recharge channel unavailable") + ErrRechargeAmount = errors.New("recharge amount invalid") + ErrRechargeNotFound = errors.New("recharge order not found") + ErrRechargeVoucher = errors.New("recharge voucher invalid") ErrIdempotencyConflict = errors.New("idempotency key reused with different request") ErrNoTaskAvailable = errors.New("no task available") ErrOrderNotFound = errors.New("task order not found") diff --git a/internal/store/manual_products.go b/internal/store/manual_products.go index 0653e9c..2ddbe51 100644 --- a/internal/store/manual_products.go +++ b/internal/store/manual_products.go @@ -20,6 +20,8 @@ type ManualProduct struct { CoverURL string `db:"cover_url"` DetailHTML string `db:"detail_html"` IsRecommended bool `db:"is_recommended"` + StartAt string `db:"start_at"` + EndAt string `db:"end_at"` ClaimedCount int64 `db:"claimed_count"` CreatedAt string `db:"created_at"` UpdatedAt string `db:"updated_at"` @@ -69,6 +71,8 @@ func manualProductSelectSQL() string { return `SELECT p.id,p.product_no,p.category_id,c.category_code,c.name AS category_name, p.payment_type,p.name,p.shop_name,p.price_amount,p.currency,p.cover_url, COALESCE(p.detail_html,'') AS detail_html,(p.is_recommended=1) AS is_recommended, + COALESCE(DATE_FORMAT(p.start_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS start_at, + COALESCE(DATE_FORMAT(p.end_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS end_at, (SELECT COUNT(*) FROM hg_trade_order o WHERE o.tenant_id=p.tenant_id AND o.product_id=p.id AND o.order_type=3 AND o.order_status NOT IN (7,8)) AS claimed_count, DATE_FORMAT(p.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at, diff --git a/internal/store/portal.go b/internal/store/portal.go index efda921..fc83fda 100644 --- a/internal/store/portal.go +++ b/internal/store/portal.go @@ -61,6 +61,24 @@ type TaskSummary struct { YesterdayCommissionAmount int64 `db:"yesterday_commission_amount" json:"yesterday_commission_amount"` } +type LeaderboardMember struct { + MemberID int64 `db:"member_id"` + Username string `db:"username"` + AvatarURL string `db:"avatar_url"` + CompletedCount int64 `db:"completed_count"` +} + +type CustomerService struct { + ID int64 `db:"id"` + ServiceCode string `db:"service_code"` + Name string `db:"name"` + AvatarURL string `db:"avatar_url"` + WorkTime string `db:"work_time"` + OnlineStatus int `db:"online_status"` + ServiceURL string `db:"service_url"` + BackupContact string `db:"backup_contact"` +} + func (s *Store) CurrentSiteConfig(ctx context.Context, tenantID int64) (json.RawMessage, error) { if s == nil || s.db == nil { return nil, errors.New("store is unavailable") @@ -151,3 +169,32 @@ func (s *Store) MemberTaskSummary(ctx context.Context, tenantID, memberID int64) tenantID, memberID, tenantID, memberID, tenantID, memberID) return &row, err } + +func (s *Store) TaskLeaderboard(ctx context.Context, tenantID int64, limit int) ([]LeaderboardMember, error) { + if s == nil || s.db == nil { + return nil, errors.New("store is unavailable") + } + rows := make([]LeaderboardMember, 0) + err := s.db.SelectContext(ctx, &rows, `SELECT m.id AS member_id,m.username, + COALESCE(a.file_url,'') AS avatar_url,COUNT(o.id) AS completed_count + FROM hg_app_member m + LEFT JOIN hg_app_member_profile p ON p.tenant_id=m.tenant_id AND p.member_id=m.id + LEFT JOIN hg_sys_attachment a ON a.id=p.avatar_attachment_id AND a.status=1 + JOIN hg_trade_order o ON o.tenant_id=m.tenant_id AND o.member_id=m.id + AND o.order_type=2 AND o.order_status=4 + WHERE m.tenant_id=? AND m.member_status=1 AND m.deleted_at IS NULL + GROUP BY m.id,m.username,a.file_url + ORDER BY completed_count DESC,m.id ASC LIMIT ?`, tenantID, limit) + return rows, err +} + +func (s *Store) ActiveCustomerServices(ctx context.Context, tenantID int64) ([]CustomerService, error) { + if s == nil || s.db == nil { + return nil, errors.New("store is unavailable") + } + rows := make([]CustomerService, 0) + err := s.db.SelectContext(ctx, &rows, `SELECT id,service_code,name,avatar_url,work_time, + online_status,service_url,backup_contact FROM hg_customer_service + WHERE tenant_id=? AND status=1 AND deleted_at IS NULL ORDER BY sort ASC,id ASC`, tenantID) + return rows, err +} diff --git a/internal/store/tasks.go b/internal/store/tasks.go index f578841..37e6575 100644 --- a/internal/store/tasks.go +++ b/internal/store/tasks.go @@ -262,10 +262,10 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder result, err := tx.ExecContext(ctx, `INSERT INTO hg_trade_order (tenant_id,order_no,member_id,task_id,product_id,product_name,order_type,quantity, unit_price_amount,total_amount,commission_amount,currency,member_level_id,rule_snapshot, - message,order_status,freeze_status,created_by,updated_by) - VALUES(?,?,?,?,0,?,2,1,0,0,?,'CNY',?,?,?,1,1,0,0)`, + message,order_status,freeze_status,matched_at,created_by,updated_by) + VALUES(?,?,?,?,0,?,2,1,0,0,?,'CNY',?,?,?,2,1,UTC_TIMESTAMP(3),0,0)`, input.TenantID, input.OrderNo, input.MemberID, task.ID, task.Title, task.CommissionAmount, - member.LevelID, rules, "等待系统匹配") + member.LevelID, rules, "任务匹配成功,请在截止时间前完成") if err != nil { if isDuplicate(err) { return nil, ErrPendingOrderExists @@ -281,9 +281,9 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder } return &TaskOrder{ID: orderID, OrderNo: input.OrderNo, TaskID: task.ID, ProductName: task.Title, CoverURL: task.CoverURL, Quantity: 1, CommissionAmount: task.CommissionAmount, Currency: "CNY", - MemberLevelID: member.LevelID, RuleSnapshot: rules, Message: "等待系统匹配", - OrderStatus: orderStatusMatching, FreezeStatus: 1, DeadlineAt: input.DeadlineAt.UTC().Format(time.RFC3339), - CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil + MemberLevelID: member.LevelID, RuleSnapshot: rules, Message: "任务匹配成功,请在截止时间前完成", + OrderStatus: orderStatusPending, FreezeStatus: 1, DeadlineAt: input.DeadlineAt.UTC().Format(time.RFC3339), + MatchedAt: time.Now().UTC().Format(time.RFC3339), CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil } func (s *Store) TaskOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*TaskOrderPage, error) { diff --git a/internal/tasks/service_test.go b/internal/tasks/service_test.go index dd91ffb..9f94eb5 100644 --- a/internal/tasks/service_test.go +++ b/internal/tasks/service_test.go @@ -29,7 +29,7 @@ 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: 1, Currency: "CNY", + return &store.TaskOrder{ID: 9, OrderNo: input.OrderNo, OrderStatus: 2, Currency: "CNY", DeadlineAt: input.DeadlineAt.Format(time.RFC3339)}, nil } func (f *fakeRepository) TaskOrders(context.Context, int64, int64, int, int, int) (*store.TaskOrderPage, error) { @@ -75,7 +75,7 @@ func TestClaimFailsClosedWithoutTaskSwitch(t *testing.T) { } } -func TestClaimBuildsMatchingOrder(t *testing.T) { +func TestClaimBuildsPendingOrder(t *testing.T) { repository := &fakeRepository{config: json.RawMessage(`{"task_enabled":true,"task_deadline_minutes":20}`)} service := New(repository) now := time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC) @@ -85,7 +85,7 @@ func TestClaimBuildsMatchingOrder(t *testing.T) { if err != nil { t.Fatal(err) } - if result.Status != "matching" || repository.claimed.HallID != 2 || + if result.Status != "pending" || repository.claimed.HallID != 2 || !repository.claimed.DeadlineAt.Equal(now.Add(20*time.Minute)) { t.Fatalf("unexpected claim: result=%#v input=%#v", result, repository.claimed) } diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 7b25bc1..d703b71 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -40,6 +40,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('../mobile/views/ClaimView.vue'), meta: { title: '领取', mobileNav: true }, }, + { + path: 'leaderboard', + name: 'MobileLeaderboard', + component: () => import('../mobile/views/LeaderboardView.vue'), + meta: { title: '荣誉榜' }, + }, { path: 'mall', name: 'MobileMall', @@ -94,6 +100,18 @@ const routes: RouteRecordRaw[] = [ component: () => import('../mobile/views/RechargeView.vue'), meta: { title: '储入' }, }, + { + path: 'rules', + name: 'MobileRules', + component: () => import('../mobile/views/RulesView.vue'), + meta: { title: '规则说明' }, + }, + { + path: 'agency', + name: 'MobileAgency', + component: () => import('../mobile/views/AgencyView.vue'), + meta: { title: '代理合作' }, + }, { path: 'profile/records', name: 'MobileFinanceRecords', @@ -124,6 +142,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('../mobile/views/FormView.vue'), meta: { title: '重设密码', form: 'reset' }, }, + { + path: 'content/platform_intro', + name: 'MobilePlatformIntro', + component: () => import('../mobile/views/PlatformIntroView.vue'), + meta: { title: '平台介绍' }, + }, { path: 'content/:key', name: 'MobileContent',