商城c端前端
This commit is contained in:
@@ -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` | 说明 |
|
| HTTP | `reason` | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -555,7 +581,7 @@ POST /v1/task-orders/claim
|
|||||||
|
|
||||||
成功返回订单摘要,包括 `id`、`order_no`、`status`、`product`、`total_amount`、`commission_amount`、`currency` 和 `deadline_at`。
|
成功返回订单摘要,包括 `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` | 说明 |
|
| HTTP | `reason` | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -708,6 +734,9 @@ GET /v1/manual-products?page=1&page_size=20&category_id=0
|
|||||||
"summary": "商品说明摘要",
|
"summary": "商品说明摘要",
|
||||||
"claimed_count": 1,
|
"claimed_count": 1,
|
||||||
"is_recommended": true,
|
"is_recommended": true,
|
||||||
|
"is_limited": true,
|
||||||
|
"start_at": "2026-10-01T00:00:00Z",
|
||||||
|
"end_at": "2026-10-31T23:59:59Z",
|
||||||
"available_actions": ["contact_support"]
|
"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 商品详情
|
### 8.2 商品详情
|
||||||
|
|
||||||
@@ -989,9 +1018,9 @@ Content-Type: application/json
|
|||||||
| 429 | `RATE_LIMITED` | 交易密码连续错误次数过多,15 分钟后重试 |
|
| 429 | `RATE_LIMITED` | 交易密码连续错误次数过多,15 分钟后重试 |
|
||||||
| 503 | `WITHDRAWAL_CHANNEL_UNAVAILABLE` | 没有可用的取出通道 |
|
| 503 | `WITHDRAWAL_CHANNEL_UNAVAILABLE` | 没有可用的取出通道 |
|
||||||
|
|
||||||
## 12. 客服、通知和上传(待实现)
|
## 12. 客服、通知和上传
|
||||||
|
|
||||||
### 12.1 客服
|
### 12.1 客服(已实现)
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /v1/customer-services
|
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`。
|
响应字段包括 `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 只开启必要权限,并显示第三方服务来源和隐私提示。
|
客服 URL 必须经服务端域名白名单校验。前端 WebView/iframe 只开启必要权限,并显示第三方服务来源和隐私提示。
|
||||||
|
|
||||||
### 12.2 通知
|
### 12.2 通知
|
||||||
|
|||||||
+17
-13
@@ -3,17 +3,21 @@ package finance
|
|||||||
import "errors"
|
import "errors"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrUnavailable = errors.New("finance service unavailable")
|
ErrUnavailable = errors.New("finance service unavailable")
|
||||||
ErrAccountTypeInvalid = errors.New("payment account type invalid")
|
ErrAccountTypeInvalid = errors.New("payment account type invalid")
|
||||||
ErrAccountFieldsInvalid = errors.New("payment account fields invalid")
|
ErrAccountFieldsInvalid = errors.New("payment account fields invalid")
|
||||||
ErrTradePassword = errors.New("trade password invalid")
|
ErrTradePassword = errors.New("trade password invalid")
|
||||||
ErrTradePasswordUnset = errors.New("trade password is not set")
|
ErrTradePasswordUnset = errors.New("trade password is not set")
|
||||||
ErrRateLimited = errors.New("trade password attempts rate limited")
|
ErrRateLimited = errors.New("trade password attempts rate limited")
|
||||||
ErrCurrencyInvalid = errors.New("currency invalid")
|
ErrCurrencyInvalid = errors.New("currency invalid")
|
||||||
ErrAmountInvalid = errors.New("amount invalid")
|
ErrAmountInvalid = errors.New("amount invalid")
|
||||||
ErrIdempotencyKey = errors.New("idempotency key invalid")
|
ErrIdempotencyKey = errors.New("idempotency key invalid")
|
||||||
ErrPageInvalid = errors.New("page invalid")
|
ErrPageInvalid = errors.New("page invalid")
|
||||||
ErrPageSizeInvalid = errors.New("page size invalid")
|
ErrPageSizeInvalid = errors.New("page size invalid")
|
||||||
ErrStatusInvalid = errors.New("withdrawal status invalid")
|
ErrStatusInvalid = errors.New("withdrawal status invalid")
|
||||||
ErrOrderIDInvalid = errors.New("withdrawal order id 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")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ type Repository interface {
|
|||||||
CreateWithdrawal(context.Context, store.WithdrawalCreation) (*store.WithdrawalOrder, error)
|
CreateWithdrawal(context.Context, store.WithdrawalCreation) (*store.WithdrawalOrder, error)
|
||||||
WithdrawalOrders(context.Context, int64, int64, int, int, int) (*store.WithdrawalPage, error)
|
WithdrawalOrders(context.Context, int64, int64, int, int, int) (*store.WithdrawalPage, error)
|
||||||
WithdrawalOrder(context.Context, int64, int64, int64) (*store.WithdrawalOrder, 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 {
|
type Service struct {
|
||||||
@@ -153,6 +157,58 @@ type WithdrawalPage struct {
|
|||||||
Total int64 `json:"total"`
|
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 {
|
func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig) *Service {
|
||||||
service := &Service{repository: repository, redis: redisClient, now: time.Now}
|
service := &Service{repository: repository, redis: redisClient, now: time.Now}
|
||||||
encryptionKey, encryptionErr := decodeKey(cfg.DataEncryptionKey)
|
encryptionKey, encryptionErr := decodeKey(cfg.DataEncryptionKey)
|
||||||
@@ -357,6 +413,134 @@ func (s *Service) WithdrawalOrder(ctx context.Context, tenantID, memberID, order
|
|||||||
return &result, nil
|
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) {
|
func (s *Service) quote(ctx context.Context, tenantID, memberID int64, input QuoteInput) (*Quote, error) {
|
||||||
quote, _, _, err := s.quoteData(ctx, tenantID, memberID, input)
|
quote, _, _, err := s.quoteData(ctx, tenantID, memberID, input)
|
||||||
return quote, err
|
return quote, err
|
||||||
@@ -537,6 +721,64 @@ func withdrawalFromStore(row store.WithdrawalOrder) Withdrawal {
|
|||||||
IdempotentReplay: row.Replayed}
|
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) {
|
func withdrawalFee(amount int64, channel store.WithdrawalChannel) (int64, error) {
|
||||||
if amount <= 0 || channel.FeeRate < 0 || channel.FeeRate > 10000 || channel.FeeFixedAmount < 0 {
|
if amount <= 0 || channel.FeeRate < 0 || channel.FeeRate > 10000 || channel.FeeFixedAmount < 0 {
|
||||||
return 0, store.ErrWithdrawalChannel
|
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]))
|
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) {
|
func decodeKey(value string) ([]byte, error) {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
if decoded, err := base64.StdEncoding.DecodeString(value); err == nil {
|
if decoded, err := base64.StdEncoding.DecodeString(value); err == nil {
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type fakeRepository struct {
|
type fakeRepository struct {
|
||||||
config json.RawMessage
|
config json.RawMessage
|
||||||
account *store.PaymentAccount
|
account *store.PaymentAccount
|
||||||
channel *store.WithdrawalChannel
|
channel *store.WithdrawalChannel
|
||||||
wallet *store.WithdrawalWallet
|
wallet *store.WithdrawalWallet
|
||||||
orders []store.WithdrawalOrder
|
orders []store.WithdrawalOrder
|
||||||
|
rechargeChannels []store.RechargeChannel
|
||||||
|
rechargeOrders []store.RechargeOrder
|
||||||
|
createdRecharge store.RechargeCreation
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) {
|
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) {
|
func (f *fakeRepository) WithdrawalOrder(context.Context, int64, int64, int64) (*store.WithdrawalOrder, error) {
|
||||||
return nil, errors.New("not implemented")
|
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) {
|
func TestQuoteCalculatesRateFeeAndLimits(t *testing.T) {
|
||||||
repository := &fakeRepository{config: json.RawMessage(`{"features":{"withdrawal_enabled":true}}`),
|
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) {
|
func TestDeterministicNumberIsStableAndScoped(t *testing.T) {
|
||||||
first := deterministicNumber("WD", 0, 1, "request-key")
|
first := deterministicNumber("WD", 0, 1, "request-key")
|
||||||
if first != deterministicNumber("WD", 0, 1, "request-key") || first == deterministicNumber("WD", 0, 2, "request-key") {
|
if first != deterministicNumber("WD", 0, 1, "request-key") || first == deterministicNumber("WD", 0, 2, "request-key") {
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ func (a *API) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/v1/banners", a.banners)
|
mux.HandleFunc("/v1/banners", a.banners)
|
||||||
mux.HandleFunc("/v1/contents/", a.content)
|
mux.HandleFunc("/v1/contents/", a.content)
|
||||||
mux.HandleFunc("/v1/member-levels", a.memberLevels)
|
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/task-halls", a.taskHalls)
|
||||||
mux.HandleFunc("/v1/tasks/eligibility", a.taskEligibility)
|
mux.HandleFunc("/v1/tasks/eligibility", a.taskEligibility)
|
||||||
mux.HandleFunc("/v1/tasks/stats", a.taskStats)
|
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.manualProducts)
|
||||||
mux.HandleFunc("/v1/manual-products/", a.manualProduct)
|
mux.HandleFunc("/v1/manual-products/", a.manualProduct)
|
||||||
mux.HandleFunc("/v1/payment-accounts", a.paymentAccounts)
|
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/quote", a.withdrawalQuote)
|
||||||
mux.HandleFunc("/v1/withdrawal-orders", a.withdrawalOrders)
|
mux.HandleFunc("/v1/withdrawal-orders", a.withdrawalOrders)
|
||||||
mux.HandleFunc("/v1/withdrawal-orders/", a.withdrawalOrder)
|
mux.HandleFunc("/v1/withdrawal-orders/", a.withdrawalOrder)
|
||||||
|
|||||||
@@ -62,6 +62,91 @@ func (a *API) withdrawalQuote(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeSuccess(w, r, result)
|
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) {
|
func (a *API) withdrawalOrders(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||||
methodNotAllowed(w, r, http.MethodGet, 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"})
|
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):
|
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)
|
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):
|
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"})
|
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):
|
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"})
|
writeError(w, r, http.StatusBadRequest, 30000, "PAGE_SIZE_INVALID", "validation failed", map[string]any{"field": "page_size"})
|
||||||
case errors.Is(err, finance.ErrStatusInvalid):
|
case errors.Is(err, finance.ErrStatusInvalid):
|
||||||
writeError(w, r, http.StatusBadRequest, 30000, "WITHDRAWAL_STATUS_INVALID", "validation failed", map[string]any{"field": "status"})
|
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):
|
case errors.Is(err, finance.ErrOrderIDInvalid):
|
||||||
writeError(w, r, http.StatusBadRequest, 30000, "WITHDRAWAL_ORDER_ID_INVALID", "validation failed", map[string]any{"field": "order_id"})
|
writeError(w, r, http.StatusBadRequest, 30000, "WITHDRAWAL_ORDER_ID_INVALID", "validation failed", map[string]any{"field": "order_id"})
|
||||||
case errors.Is(err, store.ErrPaymentAccountExists):
|
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)
|
writeError(w, r, http.StatusUnprocessableEntity, 30022, "WITHDRAWAL_DISABLED", "withdrawal feature is disabled", nil)
|
||||||
case errors.Is(err, store.ErrWithdrawalChannel):
|
case errors.Is(err, store.ErrWithdrawalChannel):
|
||||||
writeError(w, r, http.StatusServiceUnavailable, 30053, "WITHDRAWAL_CHANNEL_UNAVAILABLE", "withdrawal channel is unavailable", nil)
|
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):
|
case errors.Is(err, store.ErrWithdrawalLimit):
|
||||||
writeError(w, r, http.StatusUnprocessableEntity, 30022, "WITHDRAWAL_DAILY_LIMIT_REACHED", "daily withdrawal limit reached", nil)
|
writeError(w, r, http.StatusUnprocessableEntity, 30022, "WITHDRAWAL_DAILY_LIMIT_REACHED", "daily withdrawal limit reached", nil)
|
||||||
case errors.Is(err, store.ErrInsufficientBalance):
|
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)
|
writeError(w, r, http.StatusUnprocessableEntity, 30022, "ACCOUNT_RESTRICTED", "account is restricted", nil)
|
||||||
case errors.Is(err, store.ErrWithdrawalNotFound):
|
case errors.Is(err, store.ErrWithdrawalNotFound):
|
||||||
writeError(w, r, http.StatusNotFound, 30044, "WITHDRAWAL_ORDER_NOT_FOUND", "withdrawal order not found", nil)
|
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):
|
case errors.Is(err, store.ErrIdempotencyConflict):
|
||||||
writeError(w, r, http.StatusConflict, 30040, "IDEMPOTENCY_CONFLICT", "Idempotency-Key was reused with different request data", nil)
|
writeError(w, r, http.StatusConflict, 30040, "IDEMPOTENCY_CONFLICT", "Idempotency-Key was reused with different request data", nil)
|
||||||
case errors.Is(err, store.ErrAttachmentInvalid):
|
case errors.Is(err, store.ErrAttachmentInvalid):
|
||||||
|
|||||||
@@ -117,6 +117,46 @@ func (a *API) memberLevels(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeSuccess(w, r, items)
|
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 {
|
func (a *API) ensurePortal(w http.ResponseWriter, r *http.Request) bool {
|
||||||
if a.portal != nil {
|
if a.portal != nil {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ type Product struct {
|
|||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
ClaimedCount int64 `json:"claimed_count"`
|
ClaimedCount int64 `json:"claimed_count"`
|
||||||
IsRecommended bool `json:"is_recommended"`
|
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"`
|
AvailableActions []string `json:"available_actions"`
|
||||||
PaymentType string `json:"payment_type,omitempty"`
|
PaymentType string `json:"payment_type,omitempty"`
|
||||||
DetailText string `json:"detail_text,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},
|
Category: Category{ID: row.CategoryID, Code: row.CategoryCode, Name: row.CategoryName},
|
||||||
Name: row.Name, ShopName: row.ShopName, PriceAmount: row.PriceAmount, Currency: row.Currency,
|
Name: row.Name, ShopName: row.ShopName, PriceAmount: row.PriceAmount, Currency: row.Currency,
|
||||||
CoverURL: safeHTTPSURL(row.CoverURL), Summary: truncate(detailText, maxSummaryRunes),
|
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"}}
|
AvailableActions: []string{"contact_support"}}
|
||||||
if detail {
|
if detail {
|
||||||
result.PaymentType = row.PaymentType
|
result.PaymentType = row.PaymentType
|
||||||
|
|||||||
@@ -25,14 +25,16 @@ func TestProductsMapsSafeCard(t *testing.T) {
|
|||||||
service := New(&fakeRepository{items: []store.ManualProduct{{ID: 4, ProductNo: "DEMO-P-03",
|
service := New(&fakeRepository{items: []store.ManualProduct{{ID: 4, ProductNo: "DEMO-P-03",
|
||||||
CategoryID: 2, CategoryCode: "DAILY", CategoryName: "日用", Name: "体验商品", ShopName: "平台",
|
CategoryID: 2, CategoryCode: "DAILY", CategoryName: "日用", Name: "体验商品", ShopName: "平台",
|
||||||
PriceAmount: 12900, Currency: "CNY", CoverURL: "https://example.com/cover.png",
|
PriceAmount: 12900, Currency: "CNY", CoverURL: "https://example.com/cover.png",
|
||||||
DetailHTML: "<h3>标题</h3><script>alert(1)</script><p>内容</p>", ClaimedCount: 2, IsRecommended: true}}})
|
DetailHTML: "<h3>标题</h3><script>alert(1)</script><p>内容</p>", ClaimedCount: 2, IsRecommended: true,
|
||||||
|
StartAt: "2026-10-01T00:00:00Z", EndAt: "2026-10-31T00:00:00Z"}}})
|
||||||
|
|
||||||
result, err := service.Products(context.Background(), 0, 0, 1, 20)
|
result, err := service.Products(context.Background(), 0, 0, 1, 20)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(result.Items) != 1 || result.Items[0].CoverURL == "" || result.Items[0].Summary != "标题 内容" ||
|
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)
|
t.Fatalf("unexpected product: %#v", result.Items)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ type Repository interface {
|
|||||||
ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error)
|
ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error)
|
||||||
ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error)
|
ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error)
|
||||||
MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, 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 {
|
type Service struct {
|
||||||
@@ -95,6 +97,26 @@ type MemberLevel struct {
|
|||||||
Version int `json:"version"`
|
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 {
|
func New(repository Repository) *Service {
|
||||||
return &Service{repository: repository}
|
return &Service{repository: repository}
|
||||||
}
|
}
|
||||||
@@ -183,6 +205,51 @@ func (s *Service) MemberLevels(ctx context.Context, tenantID int64) ([]MemberLev
|
|||||||
return items, nil
|
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 {
|
func parseFeatures(raw json.RawMessage) Features {
|
||||||
var config map[string]any
|
var config map[string]any
|
||||||
if json.Unmarshal(raw, &config) != nil {
|
if json.Unmarshal(raw, &config) != nil {
|
||||||
@@ -241,6 +308,29 @@ func bannerTarget(kind int, value string) (string, string) {
|
|||||||
return "none", ""
|
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 {
|
func noticeSourceName(source int) string {
|
||||||
if source == 2 {
|
if source == 2 {
|
||||||
return "operational_simulation"
|
return "operational_simulation"
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type fakeRepository struct {
|
type fakeRepository struct {
|
||||||
config json.RawMessage
|
config json.RawMessage
|
||||||
banners []store.Banner
|
banners []store.Banner
|
||||||
content *store.Content
|
content *store.Content
|
||||||
levels []store.MemberLevel
|
levels []store.MemberLevel
|
||||||
notices []store.MemberNotice
|
notices []store.MemberNotice
|
||||||
summary *store.TaskSummary
|
summary *store.TaskSummary
|
||||||
|
leaders []store.LeaderboardMember
|
||||||
|
services []store.CustomerService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) {
|
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) {
|
func (r fakeRepository) MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error) {
|
||||||
return r.summary, nil
|
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) {
|
func TestParseFeaturesSupportsNestedAndLegacyKeys(t *testing.T) {
|
||||||
features := parseFeatures(json.RawMessage(`{
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ var (
|
|||||||
ErrWithdrawalAmount = errors.New("withdrawal amount invalid")
|
ErrWithdrawalAmount = errors.New("withdrawal amount invalid")
|
||||||
ErrWithdrawalLimit = errors.New("withdrawal daily limit reached")
|
ErrWithdrawalLimit = errors.New("withdrawal daily limit reached")
|
||||||
ErrWithdrawalNotFound = errors.New("withdrawal order not found")
|
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")
|
ErrIdempotencyConflict = errors.New("idempotency key reused with different request")
|
||||||
ErrNoTaskAvailable = errors.New("no task available")
|
ErrNoTaskAvailable = errors.New("no task available")
|
||||||
ErrOrderNotFound = errors.New("task order not found")
|
ErrOrderNotFound = errors.New("task order not found")
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ type ManualProduct struct {
|
|||||||
CoverURL string `db:"cover_url"`
|
CoverURL string `db:"cover_url"`
|
||||||
DetailHTML string `db:"detail_html"`
|
DetailHTML string `db:"detail_html"`
|
||||||
IsRecommended bool `db:"is_recommended"`
|
IsRecommended bool `db:"is_recommended"`
|
||||||
|
StartAt string `db:"start_at"`
|
||||||
|
EndAt string `db:"end_at"`
|
||||||
ClaimedCount int64 `db:"claimed_count"`
|
ClaimedCount int64 `db:"claimed_count"`
|
||||||
CreatedAt string `db:"created_at"`
|
CreatedAt string `db:"created_at"`
|
||||||
UpdatedAt string `db:"updated_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,
|
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,
|
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(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
|
(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,
|
AND o.order_type=3 AND o.order_status NOT IN (7,8)) AS claimed_count,
|
||||||
DATE_FORMAT(p.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
|
DATE_FORMAT(p.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
|
||||||
|
|||||||
@@ -61,6 +61,24 @@ type TaskSummary struct {
|
|||||||
YesterdayCommissionAmount int64 `db:"yesterday_commission_amount" json:"yesterday_commission_amount"`
|
YesterdayCommissionAmount int64 `db:"yesterday_commission_amount" json:"yesterday_commission_amount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LeaderboardMember struct {
|
||||||
|
MemberID int64 `db:"member_id"`
|
||||||
|
Username string `db:"username"`
|
||||||
|
AvatarURL string `db:"avatar_url"`
|
||||||
|
CompletedCount int64 `db:"completed_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomerService struct {
|
||||||
|
ID int64 `db:"id"`
|
||||||
|
ServiceCode string `db:"service_code"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
AvatarURL string `db:"avatar_url"`
|
||||||
|
WorkTime string `db:"work_time"`
|
||||||
|
OnlineStatus int `db:"online_status"`
|
||||||
|
ServiceURL string `db:"service_url"`
|
||||||
|
BackupContact string `db:"backup_contact"`
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) CurrentSiteConfig(ctx context.Context, tenantID int64) (json.RawMessage, error) {
|
func (s *Store) CurrentSiteConfig(ctx context.Context, tenantID int64) (json.RawMessage, error) {
|
||||||
if s == nil || s.db == nil {
|
if s == nil || s.db == nil {
|
||||||
return nil, errors.New("store is unavailable")
|
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)
|
tenantID, memberID, tenantID, memberID, tenantID, memberID)
|
||||||
return &row, err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -262,10 +262,10 @@ func (s *Store) ClaimTask(ctx context.Context, input ClaimTaskInput) (*TaskOrder
|
|||||||
result, err := tx.ExecContext(ctx, `INSERT INTO hg_trade_order
|
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,
|
(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,
|
unit_price_amount,total_amount,commission_amount,currency,member_level_id,rule_snapshot,
|
||||||
message,order_status,freeze_status,created_by,updated_by)
|
message,order_status,freeze_status,matched_at,created_by,updated_by)
|
||||||
VALUES(?,?,?,?,0,?,2,1,0,0,?,'CNY',?,?,?,1,1,0,0)`,
|
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,
|
input.TenantID, input.OrderNo, input.MemberID, task.ID, task.Title, task.CommissionAmount,
|
||||||
member.LevelID, rules, "等待系统匹配")
|
member.LevelID, rules, "任务匹配成功,请在截止时间前完成")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isDuplicate(err) {
|
if isDuplicate(err) {
|
||||||
return nil, ErrPendingOrderExists
|
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,
|
return &TaskOrder{ID: orderID, OrderNo: input.OrderNo, TaskID: task.ID, ProductName: task.Title,
|
||||||
CoverURL: task.CoverURL, Quantity: 1, CommissionAmount: task.CommissionAmount, Currency: "CNY",
|
CoverURL: task.CoverURL, Quantity: 1, CommissionAmount: task.CommissionAmount, Currency: "CNY",
|
||||||
MemberLevelID: member.LevelID, RuleSnapshot: rules, Message: "等待系统匹配",
|
MemberLevelID: member.LevelID, RuleSnapshot: rules, Message: "任务匹配成功,请在截止时间前完成",
|
||||||
OrderStatus: orderStatusMatching, FreezeStatus: 1, DeadlineAt: input.DeadlineAt.UTC().Format(time.RFC3339),
|
OrderStatus: orderStatusPending, FreezeStatus: 1, DeadlineAt: input.DeadlineAt.UTC().Format(time.RFC3339),
|
||||||
CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil
|
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) {
|
func (s *Store) TaskOrders(ctx context.Context, tenantID, memberID int64, status, offset, limit int) (*TaskOrderPage, error) {
|
||||||
|
|||||||
@@ -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) {
|
func (f *fakeRepository) ClaimTask(_ context.Context, input store.ClaimTaskInput) (*store.TaskOrder, error) {
|
||||||
f.claimed = input
|
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
|
DeadlineAt: input.DeadlineAt.Format(time.RFC3339)}, nil
|
||||||
}
|
}
|
||||||
func (f *fakeRepository) TaskOrders(context.Context, int64, int64, int, int, int) (*store.TaskOrderPage, error) {
|
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}`)}
|
repository := &fakeRepository{config: json.RawMessage(`{"task_enabled":true,"task_deadline_minutes":20}`)}
|
||||||
service := New(repository)
|
service := New(repository)
|
||||||
now := time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 10, 9, 2, 0, 0, 0, time.UTC)
|
||||||
@@ -85,7 +85,7 @@ func TestClaimBuildsMatchingOrder(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)) {
|
!repository.claimed.DeadlineAt.Equal(now.Add(20*time.Minute)) {
|
||||||
t.Fatalf("unexpected claim: result=%#v input=%#v", result, repository.claimed)
|
t.Fatalf("unexpected claim: result=%#v input=%#v", result, repository.claimed)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('../mobile/views/ClaimView.vue'),
|
component: () => import('../mobile/views/ClaimView.vue'),
|
||||||
meta: { title: '领取', mobileNav: true },
|
meta: { title: '领取', mobileNav: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'leaderboard',
|
||||||
|
name: 'MobileLeaderboard',
|
||||||
|
component: () => import('../mobile/views/LeaderboardView.vue'),
|
||||||
|
meta: { title: '荣誉榜' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'mall',
|
path: 'mall',
|
||||||
name: 'MobileMall',
|
name: 'MobileMall',
|
||||||
@@ -94,6 +100,18 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('../mobile/views/RechargeView.vue'),
|
component: () => import('../mobile/views/RechargeView.vue'),
|
||||||
meta: { title: '储入' },
|
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',
|
path: 'profile/records',
|
||||||
name: 'MobileFinanceRecords',
|
name: 'MobileFinanceRecords',
|
||||||
@@ -124,6 +142,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('../mobile/views/FormView.vue'),
|
component: () => import('../mobile/views/FormView.vue'),
|
||||||
meta: { title: '重设密码', form: 'reset' },
|
meta: { title: '重设密码', form: 'reset' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'content/platform_intro',
|
||||||
|
name: 'MobilePlatformIntro',
|
||||||
|
component: () => import('../mobile/views/PlatformIntroView.vue'),
|
||||||
|
meta: { title: '平台介绍' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'content/:key',
|
path: 'content/:key',
|
||||||
name: 'MobileContent',
|
name: 'MobileContent',
|
||||||
|
|||||||
Reference in New Issue
Block a user