diff --git a/docs/olons3/代理合作.png b/docs/olons3/代理合作.png new file mode 100644 index 0000000..30c083b Binary files /dev/null and b/docs/olons3/代理合作.png differ diff --git a/docs/olons3/规则说明.png b/docs/olons3/规则说明.png new file mode 100644 index 0000000..5f22916 Binary files /dev/null and b/docs/olons3/规则说明.png differ diff --git a/docs/移动端任务与账户平台前后端对接文档.md b/docs/移动端任务与账户平台前后端对接文档.md index a0e72a3..d107269 100644 --- a/docs/移动端任务与账户平台前后端对接文档.md +++ b/docs/移动端任务与账户平台前后端对接文档.md @@ -25,7 +25,7 @@ | 个人资料、密码 | 已实现 | 会员概要、头像、登录密码和交易密码 | | 首页、内容、Banner、等级 | 已实现 | 只返回已发布且已生效的配置和内容 | | 任务、订单、任务提交 | 已实现 | 大厅、资格预检、领单、查询和凭证提交;审核结算由后台负责 | -| 钱包、账户明细 | 待实现 | 钱包和不可变流水表已存在 | +| 钱包、账户明细 | 已实现 | 钱包余额随会员资料返回;账户明细读取不可变流水表并支持分页、业务类型和收支方向筛选 | | 收款账户、取出 | 已实现(沙箱) | 加密绑定、试算、申请、资金冻结和记录查询;上线前仍需支付与合规评审 | | 储入 | 待实现 | 需在支付与合规评审后开放 | | 手工商城 | 已实现 | 已发布商品列表、分类筛选和详情;当前只提供客服咨询动作 | @@ -1040,7 +1040,28 @@ GET /v1/notices/{notice_id} POST /v1/notices/{notice_id}/read ``` -列表包含当前会员通知和 `member_id=0` 的全体通知,只返回有效期内且未撤回记录。当前数据库没有会员通知已读关系表;如需精确已读状态,实现前应补充数据模型。 +当前已实现通知列表接口。列表包含当前会员通知和 `member_id=0` 的全体通知,只返回有效期内且未撤回记录,按创建时间倒序排列。 + +```json +{ + "items": [ + { + "id": 1, + "title": "系统维护通知", + "content": "通知内容", + "source_type": "real", + "start_at": "2026-10-13T00:00:00Z", + "end_at": "2026-10-20T00:00:00Z", + "created_at": "2026-10-13T00:00:00Z" + } + ], + "page": 1, + "page_size": 20, + "total": 1 +} +``` + +`source_type` 为 `real`(系统通知)或 `operational_simulation`(活动通知)。详情和已读接口尚未实现;当前数据库没有会员通知已读关系表,如需精确已读状态,实现前应补充数据模型。 ### 12.3 上传 diff --git a/internal/finance/errors.go b/internal/finance/errors.go index bc95598..1904ce9 100644 --- a/internal/finance/errors.go +++ b/internal/finance/errors.go @@ -20,4 +20,5 @@ var ( ErrRechargeOrderIDInvalid = errors.New("recharge order id invalid") ErrRemarkInvalid = errors.New("remark invalid") ErrVoucherInvalid = errors.New("voucher attachment ids invalid") + ErrLedgerFilterInvalid = errors.New("wallet ledger filter invalid") ) diff --git a/internal/finance/service.go b/internal/finance/service.go index a8fc943..bf2db1c 100644 --- a/internal/finance/service.go +++ b/internal/finance/service.go @@ -55,6 +55,7 @@ type Repository interface { 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) + WalletLedgers(context.Context, int64, int64, string, int, int, int) (*store.WalletLedgerPage, error) } type Service struct { @@ -209,6 +210,29 @@ type RechargePage struct { HasNext bool `json:"has_next"` } +type WalletLedger struct { + ID int64 `json:"id"` + LedgerNo string `json:"ledger_no"` + BizType string `json:"biz_type"` + BizNo string `json:"biz_no"` + EntryType string `json:"entry_type"` + Direction string `json:"direction"` + Amount int64 `json:"amount"` + BeforeAmount int64 `json:"before_amount"` + AfterAmount int64 `json:"after_amount"` + Currency string `json:"currency"` + Remark string `json:"remark"` + OccurredAt string `json:"occurred_at"` +} + +type WalletLedgerPage struct { + Items []WalletLedger `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) @@ -231,7 +255,7 @@ func New(repository Repository, redisClient *redis.Client, cfg config.AuthConfig } func (s *Service) PaymentAccounts(ctx context.Context, tenantID, memberID int64) ([]PaymentAccount, error) { - if !s.ready { + if s.repository == nil { return nil, ErrUnavailable } rows, err := s.repository.PaymentAccounts(ctx, tenantID, memberID) @@ -368,7 +392,7 @@ func (s *Service) CreateWithdrawal(ctx context.Context, tenantID, memberID int64 } func (s *Service) WithdrawalOrders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*WithdrawalPage, error) { - if !s.ready { + if s.repository == nil { return nil, ErrUnavailable } if page == 0 { @@ -399,7 +423,7 @@ func (s *Service) WithdrawalOrders(ctx context.Context, tenantID, memberID int64 } func (s *Service) WithdrawalOrder(ctx context.Context, tenantID, memberID, orderID int64) (*Withdrawal, error) { - if !s.ready { + if s.repository == nil { return nil, ErrUnavailable } if orderID <= 0 { @@ -414,7 +438,7 @@ func (s *Service) WithdrawalOrder(ctx context.Context, tenantID, memberID, order } func (s *Service) RechargeChannels(ctx context.Context, tenantID int64) ([]RechargeChannel, error) { - if !s.ready { + if s.repository == nil { return nil, ErrUnavailable } rows, err := s.repository.RechargeChannels(ctx, tenantID) @@ -432,7 +456,7 @@ func (s *Service) RechargeChannels(ctx context.Context, tenantID int64) ([]Recha } func (s *Service) CreateRecharge(ctx context.Context, tenantID, memberID int64, input CreateRechargeInput) (*Recharge, error) { - if !s.ready { + if s.repository == nil { return nil, ErrUnavailable } input.Currency = strings.ToUpper(strings.TrimSpace(input.Currency)) @@ -495,7 +519,7 @@ func (s *Service) CreateRecharge(ctx context.Context, tenantID, memberID int64, } func (s *Service) RechargeOrders(ctx context.Context, tenantID, memberID int64, page, pageSize int, status string) (*RechargePage, error) { - if !s.ready { + if s.repository == nil { return nil, ErrUnavailable } if page == 0 { @@ -527,7 +551,7 @@ func (s *Service) RechargeOrders(ctx context.Context, tenantID, memberID int64, } func (s *Service) RechargeOrder(ctx context.Context, tenantID, memberID, orderID int64) (*Recharge, error) { - if !s.ready { + if s.repository == nil { return nil, ErrUnavailable } if orderID <= 0 { @@ -541,6 +565,57 @@ func (s *Service) RechargeOrder(ctx context.Context, tenantID, memberID, orderID return &result, nil } +func (s *Service) WalletLedgers(ctx context.Context, tenantID, memberID int64, page, pageSize int, bizType, direction string) (*WalletLedgerPage, error) { + if s.repository == nil { + 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 + } + bizType = strings.TrimSpace(bizType) + if len(bizType) > 32 { + return nil, ErrLedgerFilterInvalid + } + directionCode := 0 + switch strings.ToLower(strings.TrimSpace(direction)) { + case "", "all": + case "income": + directionCode = 1 + case "expense": + directionCode = 2 + default: + return nil, ErrLedgerFilterInvalid + } + rows, err := s.repository.WalletLedgers(ctx, tenantID, memberID, bizType, directionCode, (page-1)*pageSize, pageSize) + if err != nil { + return nil, err + } + items := make([]WalletLedger, 0, len(rows.Items)) + for _, row := range rows.Items { + directionName := "expense" + if row.Direction == 1 { + directionName = "income" + } + items = append(items, WalletLedger{ + ID: row.ID, LedgerNo: row.LedgerNo, BizType: row.BizType, BizNo: row.BizNo, + EntryType: row.EntryType, Direction: directionName, Amount: row.Amount, + BeforeAmount: row.BeforeAmount, AfterAmount: row.AfterAmount, Currency: row.Currency, + Remark: row.Remark, OccurredAt: row.OccurredAt, + }) + } + return &WalletLedgerPage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total, + HasNext: int64(page*pageSize) < rows.Total}, 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 diff --git a/internal/finance/service_test.go b/internal/finance/service_test.go index 11a90f8..44e52f5 100644 --- a/internal/finance/service_test.go +++ b/internal/finance/service_test.go @@ -19,6 +19,7 @@ type fakeRepository struct { orders []store.WithdrawalOrder rechargeChannels []store.RechargeChannel rechargeOrders []store.RechargeOrder + ledgers []store.WalletLedger createdRecharge store.RechargeCreation } @@ -67,6 +68,9 @@ func (f *fakeRepository) RechargeOrders(context.Context, int64, int64, int, int, func (f *fakeRepository) RechargeOrder(context.Context, int64, int64, int64) (*store.RechargeOrder, error) { return nil, errors.New("not implemented") } +func (f *fakeRepository) WalletLedgers(context.Context, int64, int64, string, int, int, int) (*store.WalletLedgerPage, error) { + return &store.WalletLedgerPage{Items: f.ledgers, Total: int64(len(f.ledgers))}, nil +} func TestQuoteCalculatesRateFeeAndLimits(t *testing.T) { repository := &fakeRepository{config: json.RawMessage(`{"features":{"withdrawal_enabled":true}}`), @@ -127,7 +131,7 @@ 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} + service := New(repository, nil, config.AuthConfig{}) result, err := service.CreateRecharge(context.Background(), 0, 9, CreateRechargeInput{ ChannelID: 4, Amount: 50000, Currency: "cny", Remark: "测试", }) @@ -157,6 +161,43 @@ func TestRechargeOrdersMapsStatusAndPagination(t *testing.T) { } } +func TestFinanceOrderListsDoNotRequireEncryptionKeys(t *testing.T) { + repository := &fakeRepository{ + orders: []store.WithdrawalOrder{{ID: 2, WithdrawalNo: "WD2", Status: 1}}, + rechargeOrders: []store.RechargeOrder{{ID: 3, RechargeNo: "RC3", Status: 1}}, + } + service := New(repository, nil, config.AuthConfig{}) + + withdrawals, err := service.WithdrawalOrders(context.Background(), 0, 9, 1, 20, "all") + if err != nil || len(withdrawals.Items) != 1 { + t.Fatalf("withdrawal records should remain readable without encryption keys: %#v, %v", withdrawals, err) + } + recharges, err := service.RechargeOrders(context.Background(), 0, 9, 1, 20, "all") + if err != nil || len(recharges.Items) != 1 { + t.Fatalf("recharge records should remain readable without encryption keys: %#v, %v", recharges, err) + } + if _, err = service.PaymentAccounts(context.Background(), 0, 9); err != nil { + t.Fatalf("masked payment accounts should remain readable without encryption keys, got %v", err) + } + if _, err = service.CreatePaymentAccount(context.Background(), 0, 9, PaymentAccountInput{}); !errors.Is(err, ErrUnavailable) { + t.Fatalf("sensitive finance operations must remain unavailable without encryption keys, got %v", err) + } +} + +func TestWalletLedgersDoNotRequireEncryptionKeys(t *testing.T) { + service := New(&fakeRepository{ledgers: []store.WalletLedger{{ + ID: 7, LedgerNo: "WL7", Direction: 1, Amount: 947, Remark: "下级交易返佣", + }}}, nil, config.AuthConfig{}) + result, err := service.WalletLedgers(context.Background(), 0, 9, 1, 20, "", "all") + if err != nil || result.Total != 1 || len(result.Items) != 1 || + result.Items[0].Direction != "income" || result.Items[0].Amount != 947 { + t.Fatalf("wallet ledgers should remain readable without encryption keys: %#v, %v", result, err) + } + if _, err = service.WalletLedgers(context.Background(), 0, 9, 1, 20, "", "sideways"); !errors.Is(err, ErrLedgerFilterInvalid) { + t.Fatalf("expected invalid ledger direction, 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 fccf52e..be578d8 100644 --- a/internal/httpapi/api.go +++ b/internal/httpapi/api.go @@ -38,6 +38,7 @@ func (a *API) Register(mux *http.ServeMux) { mux.HandleFunc("/v1/member-levels", a.memberLevels) mux.HandleFunc("/v1/leaderboard", a.leaderboard) mux.HandleFunc("/v1/customer-services", a.customerServices) + mux.HandleFunc("/v1/notices", a.notices) mux.HandleFunc("/v1/task-halls", a.taskHalls) mux.HandleFunc("/v1/tasks/eligibility", a.taskEligibility) mux.HandleFunc("/v1/tasks/stats", a.taskStats) @@ -47,6 +48,7 @@ func (a *API) Register(mux *http.ServeMux) { mux.HandleFunc("/v1/manual-products", a.manualProducts) mux.HandleFunc("/v1/manual-products/", a.manualProduct) mux.HandleFunc("/v1/payment-accounts", a.paymentAccounts) + mux.HandleFunc("/v1/wallet/ledgers", a.walletLedgers) mux.HandleFunc("/v1/recharge/channels", a.rechargeChannels) mux.HandleFunc("/v1/recharge-orders", a.rechargeOrders) mux.HandleFunc("/v1/recharge-orders/", a.rechargeOrder) diff --git a/internal/httpapi/finance.go b/internal/httpapi/finance.go index 7189999..083159f 100644 --- a/internal/httpapi/finance.go +++ b/internal/httpapi/finance.go @@ -41,6 +41,34 @@ func (a *API) paymentAccounts(w http.ResponseWriter, r *http.Request) { writeSuccess(w, r, result) } +func (a *API) walletLedgers(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 + } + 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.WalletLedgers(r.Context(), principal.TenantID, principal.MemberID, + page, pageSize, r.URL.Query().Get("biz_type"), r.URL.Query().Get("direction")) + if err != nil { + writeFinanceError(w, r, err) + return + } + writeSuccess(w, r, result) +} + func (a *API) withdrawalQuote(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { methodNotAllowed(w, r, http.MethodPost) @@ -256,6 +284,8 @@ func writeFinanceError(w http.ResponseWriter, r *http.Request, err error) { writeError(w, r, http.StatusBadRequest, 30000, "PAGE_INVALID", "validation failed", map[string]any{"field": "page"}) case errors.Is(err, finance.ErrPageSizeInvalid): writeError(w, r, http.StatusBadRequest, 30000, "PAGE_SIZE_INVALID", "validation failed", map[string]any{"field": "page_size"}) + case errors.Is(err, finance.ErrLedgerFilterInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "LEDGER_FILTER_INVALID", "validation failed", nil) 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): diff --git a/internal/httpapi/portal.go b/internal/httpapi/portal.go index a2d3b81..881d861 100644 --- a/internal/httpapi/portal.go +++ b/internal/httpapi/portal.go @@ -157,6 +157,36 @@ func (a *API) customerServices(w http.ResponseWriter, r *http.Request) { writeSuccess(w, r, items) } +func (a *API) notices(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 + } + page, ok := optionalPositiveInt(w, r, "page") + if !ok { + writePortalError(w, r, portal.ErrPageInvalid) + return + } + pageSize, ok := optionalPositiveInt(w, r, "page_size") + if !ok { + writePortalError(w, r, portal.ErrPageSizeInvalid) + return + } + result, err := a.portal.Notices(r.Context(), principal.TenantID, principal.MemberID, page, pageSize) + if err != nil { + writePortalError(w, r, err) + return + } + writeSuccess(w, r, result) +} + func (a *API) ensurePortal(w http.ResponseWriter, r *http.Request) bool { if a.portal != nil { return true @@ -171,6 +201,10 @@ func writePortalError(w http.ResponseWriter, r *http.Request, err error) { writeError(w, r, http.StatusBadRequest, 30000, "BANNER_POSITION_INVALID", "validation failed", map[string]any{"field": "position"}) case errors.Is(err, portal.ErrContentKeyInvalid): writeError(w, r, http.StatusBadRequest, 30000, "CONTENT_KEY_INVALID", "validation failed", map[string]any{"field": "content_key"}) + case errors.Is(err, portal.ErrPageInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "PAGE_INVALID", "validation failed", map[string]any{"field": "page"}) + case errors.Is(err, portal.ErrPageSizeInvalid): + writeError(w, r, http.StatusBadRequest, 30000, "PAGE_SIZE_INVALID", "validation failed", map[string]any{"field": "page_size"}) case errors.Is(err, store.ErrResourceNotFound): writeError(w, r, http.StatusNotFound, 30044, "RESOURCE_NOT_FOUND", "resource not found", nil) default: diff --git a/internal/portal/errors.go b/internal/portal/errors.go index 17385ca..cc581aa 100644 --- a/internal/portal/errors.go +++ b/internal/portal/errors.go @@ -5,4 +5,6 @@ import "errors" var ( ErrPositionInvalid = errors.New("banner position invalid") ErrContentKeyInvalid = errors.New("content key invalid") + ErrPageInvalid = errors.New("page invalid") + ErrPageSizeInvalid = errors.New("page size invalid") ) diff --git a/internal/portal/service.go b/internal/portal/service.go index 3cc6b45..9ec5c5f 100644 --- a/internal/portal/service.go +++ b/internal/portal/service.go @@ -22,6 +22,7 @@ type Repository interface { PublishedContent(context.Context, int64, string) (*store.Content, error) ActiveMemberLevels(context.Context, int64) ([]store.MemberLevel, error) ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error) + MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error) MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error) TaskLeaderboard(context.Context, int64, int) ([]store.LeaderboardMember, error) ActiveCustomerServices(context.Context, int64) ([]store.CustomerService, error) @@ -53,6 +54,14 @@ type Notice struct { SourceType string `json:"source_type"` StartAt string `json:"start_at"` EndAt string `json:"end_at"` + CreatedAt string `json:"created_at"` +} + +type NoticePage struct { + Items []Notice `json:"items"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int64 `json:"total"` } type Home struct { @@ -168,6 +177,32 @@ func (s *Service) Banners(ctx context.Context, tenantID int64, position string) return items, nil } +func (s *Service) Notices(ctx context.Context, tenantID, memberID int64, page, pageSize int) (*NoticePage, error) { + if page == 0 { + page = 1 + } + if pageSize == 0 { + pageSize = 20 + } + if page < 1 { + return nil, ErrPageInvalid + } + if pageSize < 1 || pageSize > 100 { + return nil, ErrPageSizeInvalid + } + rows, err := s.repository.MemberNotices(ctx, tenantID, memberID, (page-1)*pageSize, pageSize) + if err != nil { + return nil, err + } + items := make([]Notice, 0, len(rows.Items)) + for _, row := range rows.Items { + items = append(items, Notice{ID: row.ID, Title: row.Title, Content: row.Content, + SourceType: noticeSourceName(row.SourceType), StartAt: row.StartAt, EndAt: row.EndAt, + CreatedAt: row.CreatedAt}) + } + return &NoticePage{Items: items, Page: page, PageSize: pageSize, Total: rows.Total}, nil +} + func (s *Service) Content(ctx context.Context, tenantID int64, key string) (*Content, error) { key = strings.ToLower(strings.TrimSpace(key)) if !contentKeyPattern.MatchString(key) { @@ -256,14 +291,14 @@ func parseFeatures(raw json.RawMessage) Features { return Features{} } return Features{ - TaskEnabled: findBool(config, true, "task_enabled", "taskEnabled", "TASK"), - RechargeEnabled: findBool(config, true, "recharge_enabled", "rechargeEnabled", "RECHARGE"), - WithdrawalEnabled: findBool(config, true, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"), - ManualMallEnabled: findBool(config, true, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"), + TaskEnabled: findBool(config, "task_enabled", "taskEnabled", "TASK"), + RechargeEnabled: findBool(config, "recharge_enabled", "rechargeEnabled", "RECHARGE"), + WithdrawalEnabled: findBool(config, "withdrawal_enabled", "withdrawalEnabled", "WITHDRAWAL"), + ManualMallEnabled: findBool(config, "manual_mall_enabled", "manualMallEnabled", "SHOP_STATUS"), } } -func findBool(config map[string]any, fallback bool, keys ...string) bool { +func findBool(config map[string]any, keys ...string) bool { for _, key := range keys { if value, ok := config[key]; ok { return boolValue(value) @@ -276,7 +311,7 @@ func findBool(config map[string]any, fallback bool, keys ...string) bool { } } } - return fallback + return false } func boolValue(value any) bool { diff --git a/internal/portal/service_test.go b/internal/portal/service_test.go index d2aa286..e982a0d 100644 --- a/internal/portal/service_test.go +++ b/internal/portal/service_test.go @@ -13,14 +13,15 @@ import ( ) type fakeRepository struct { - config json.RawMessage - banners []store.Banner - content *store.Content - levels []store.MemberLevel - notices []store.MemberNotice - summary *store.TaskSummary - leaders []store.LeaderboardMember - services []store.CustomerService + config json.RawMessage + banners []store.Banner + content *store.Content + levels []store.MemberLevel + notices []store.MemberNotice + summary *store.TaskSummary + leaders []store.LeaderboardMember + services []store.CustomerService + noticePage *store.MemberNoticePage } func (r fakeRepository) CurrentSiteConfig(context.Context, int64) (json.RawMessage, error) { @@ -41,6 +42,12 @@ func (r fakeRepository) ActiveMemberLevels(context.Context, int64) ([]store.Memb func (r fakeRepository) ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error) { return r.notices, nil } +func (r fakeRepository) MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error) { + if r.noticePage == nil { + return &store.MemberNoticePage{Items: []store.MemberNotice{}}, nil + } + return r.noticePage, nil +} func (r fakeRepository) MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error) { return r.summary, nil } @@ -61,13 +68,6 @@ func TestParseFeaturesSupportsNestedAndLegacyKeys(t *testing.T) { } } -func TestParseFeaturesDefaultsToEnabled(t *testing.T) { - features := parseFeatures(json.RawMessage(`{"siteName":"demo"}`)) - if !features.TaskEnabled || !features.RechargeEnabled || !features.WithdrawalEnabled || !features.ManualMallEnabled { - t.Fatalf("unexpected feature defaults: %+v", features) - } -} - func TestBannersSanitizeTargets(t *testing.T) { service := New(fakeRepository{banners: []store.Banner{ {ID: 1, TargetType: 2, TargetValue: "/receive"}, @@ -171,3 +171,25 @@ func TestCustomerServicesSanitizeURLsAndMapStatus(t *testing.T) { t.Fatalf("unexpected customer services: %+v", items) } } + +func TestNoticesMapsPageAndSourceType(t *testing.T) { + service := New(fakeRepository{noticePage: &store.MemberNoticePage{ + Items: []store.MemberNotice{{ID: 3, Title: "活动通知", SourceType: 2, CreatedAt: "2026-10-13T08:00:00Z"}}, + Total: 21, + }}) + page, err := service.Notices(context.Background(), 1, 7, 2, 20) + if err != nil { + t.Fatal(err) + } + if page.Page != 2 || page.PageSize != 20 || page.Total != 21 || len(page.Items) != 1 || + page.Items[0].SourceType != "operational_simulation" || page.Items[0].CreatedAt == "" { + t.Fatalf("unexpected notice page: %+v", page) + } +} + +func TestNoticesRejectsOversizedPage(t *testing.T) { + service := New(fakeRepository{}) + if _, err := service.Notices(context.Background(), 1, 7, 1, 101); !errors.Is(err, ErrPageSizeInvalid) { + t.Fatalf("error = %v, want %v", err, ErrPageSizeInvalid) + } +} diff --git a/internal/store/portal.go b/internal/store/portal.go index fc83fda..a2de4bd 100644 --- a/internal/store/portal.go +++ b/internal/store/portal.go @@ -53,6 +53,12 @@ type MemberNotice struct { SourceType int `db:"source_type"` StartAt string `db:"start_at"` EndAt string `db:"end_at"` + CreatedAt string `db:"created_at"` +} + +type MemberNoticePage struct { + Items []MemberNotice + Total int64 } type TaskSummary struct { @@ -147,13 +153,35 @@ func (s *Store) ActiveMemberNotices(ctx context.Context, tenantID, memberID int6 rows := make([]MemberNotice, 0) err := s.db.SelectContext(ctx, &rows, `SELECT id,title,content,source_type, DATE_FORMAT(start_at,'%Y-%m-%dT%H:%i:%sZ') AS start_at, - DATE_FORMAT(end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_at + DATE_FORMAT(end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_at, + DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at FROM hg_app_member_notice WHERE tenant_id=? AND member_id IN (0,?) AND notice_status=1 AND start_at<=UTC_TIMESTAMP(3) AND end_at>UTC_TIMESTAMP(3) ORDER BY start_at DESC,id DESC LIMIT ?`, tenantID, memberID, limit) return rows, err } +func (s *Store) MemberNotices(ctx context.Context, tenantID, memberID int64, offset, limit int) (*MemberNoticePage, error) { + if s == nil || s.db == nil { + return nil, errors.New("store is unavailable") + } + const where = ` WHERE tenant_id=? AND member_id IN (0,?) AND notice_status=1 + AND start_at<=UTC_TIMESTAMP(3) AND end_at>UTC_TIMESTAMP(3)` + args := []any{tenantID, memberID} + var total int64 + if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_app_member_notice`+where, args...); err != nil { + return nil, err + } + items := make([]MemberNotice, 0) + args = append(args, limit, offset) + err := s.db.SelectContext(ctx, &items, `SELECT id,title,content,source_type, + DATE_FORMAT(start_at,'%Y-%m-%dT%H:%i:%sZ') AS start_at, + DATE_FORMAT(end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_at, + DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at + FROM hg_app_member_notice`+where+` ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?`, args...) + return &MemberNoticePage{Items: items, Total: total}, err +} + func (s *Store) MemberTaskSummary(ctx context.Context, tenantID, memberID int64) (*TaskSummary, error) { if s == nil || s.db == nil { return nil, errors.New("store is unavailable") diff --git a/internal/store/wallet_ledgers.go b/internal/store/wallet_ledgers.go new file mode 100644 index 0000000..014f017 --- /dev/null +++ b/internal/store/wallet_ledgers.go @@ -0,0 +1,50 @@ +package store + +import ( + "context" +) + +type WalletLedger struct { + ID int64 `db:"id"` + LedgerNo string `db:"ledger_no"` + BizType string `db:"biz_type"` + BizNo string `db:"biz_no"` + EntryType string `db:"entry_type"` + Direction int `db:"direction"` + Amount int64 `db:"amount"` + BeforeAmount int64 `db:"before_amount"` + AfterAmount int64 `db:"after_amount"` + Currency string `db:"currency"` + Remark string `db:"remark"` + OccurredAt string `db:"occurred_at"` +} + +type WalletLedgerPage struct { + Items []WalletLedger + Total int64 +} + +func (s *Store) WalletLedgers(ctx context.Context, tenantID, memberID int64, bizType string, direction, offset, limit int) (*WalletLedgerPage, error) { + where := ` WHERE tenant_id=? AND member_id=?` + args := []any{tenantID, memberID} + if bizType != "" { + where += ` AND biz_type=?` + args = append(args, bizType) + } + if direction > 0 { + where += ` AND direction=?` + args = append(args, direction) + } + + var total int64 + if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_wallet_ledger`+where, args...); err != nil { + return nil, err + } + args = append(args, limit, offset) + items := make([]WalletLedger, 0) + err := s.db.SelectContext(ctx, &items, `SELECT id,ledger_no,biz_type,biz_no,entry_type,direction, + amount,before_amount,after_amount,currency,remark, + DATE_FORMAT(occurred_at,'%Y-%m-%dT%H:%i:%sZ') AS occurred_at + FROM hg_wallet_ledger`+where+` ORDER BY occurred_at DESC,id DESC LIMIT ? OFFSET ?`, args...) + return &WalletLedgerPage{Items: items, Total: total}, err +} diff --git a/web/src/mobile/styles.css b/web/src/mobile/styles.css index 17ca571..16a69ad 100644 --- a/web/src/mobile/styles.css +++ b/web/src/mobile/styles.css @@ -1038,7 +1038,6 @@ body { margin: 7px 0 14px; color: var(--m-muted); } - .m-product-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -2388,6 +2387,173 @@ body { color: #ff681c; } +.m-notices-page { + padding-inline: 0; + background: #f7f7f7; +} +.m-notices-page .m-page-header { + padding-inline: 20px; + background: #fff; +} +.m-notices-list { + display: grid; + gap: 10px; + padding: 12px 14px 0; +} +.m-notices-item { + padding: 16px; + border: 1px solid #eeeeec; + border-radius: 8px; + background: #fff; +} +.m-notices-heading { + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + align-items: center; + gap: 11px; +} +.m-notices-icon { + width: 42px; + height: 42px; + border-radius: 50%; + display: grid; + place-items: center; + color: #fff; + background: linear-gradient(145deg, #ffa840, #f2661c); +} +.m-notices-icon .n-icon { + font-size: 23px; +} +.m-notices-heading h2 { + margin: 0 0 5px; + overflow-wrap: anywhere; + font-size: 17px; + line-height: 1.35; + font-weight: 600; +} +.m-notices-tag { + display: inline-block; + padding: 2px 7px; + border-radius: 4px; + color: #7a531c; + background: #fff4df; + font-size: 12px; + line-height: 18px; +} +.m-notices-tag.is-activity { + color: #24704b; + background: #e9f7ef; +} +.m-notices-item > p { + margin: 13px 0 10px; + color: #3f4247; + font-size: 15px; + line-height: 1.7; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.m-notices-item > time { + color: #999; + font-size: 13px; +} +.m-notices-footer { + min-height: 58px; + display: grid; + place-items: center; + padding: 8px 14px 20px; + color: #999; + font-size: 13px; +} +.m-notices-footer p { + margin: 0; + color: #b05b24; +} +.m-notices-footer button { + min-width: 110px; + min-height: 40px; + border: 0; + background: transparent; + color: #986024; +} +.m-notices-footer button:disabled { + opacity: 0.55; +} + +.m-ledgers { + padding-inline: 0; + background: #f7f7f7; +} +.m-ledgers .m-page-header { + padding-inline: 20px; + background: #fff; +} +.m-ledger-tabs { + height: 58px; + padding: 8px 18px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + background: #fff; + border-top: 1px solid #f2f2f2; +} +.m-ledger-tabs button { + border: 0; + border-radius: 6px; + background: #f5f5f5; + color: #777; + font-size: 15px; +} +.m-ledger-tabs button.active { + color: #fff; + background: #986024; +} +.m-ledger-list { + padding: 12px 14px 24px; + display: grid; + gap: 10px; +} +.m-ledger-item { + padding: 15px 16px; + border-radius: 8px; + background: #fff; + box-shadow: 0 3px 14px rgba(73, 54, 31, 0.06); +} +.m-ledger-head { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.m-ledger-head strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 16px; + font-weight: 500; +} +.m-ledger-head b { + flex: 0 0 auto; + font-size: 18px; + font-weight: 600; +} +.m-ledger-head .is-income { + color: #1f9d68; +} +.m-ledger-head .is-expense { + color: #d55d43; +} +.m-ledger-item p { + margin: 9px 0 6px; + color: #444; + font-size: 15px; +} +.m-ledger-item time { + color: #9a9da3; + font-size: 13px; +} + @media (min-width: 751px) { body { background: #e9eaec; @@ -2413,6 +2579,12 @@ body { margin-inline: -14px; padding-inline: 14px; } + .m-product-detail { + margin-inline: -14px; + } + .m-plain-form { + margin-inline: -14px; + } } .m-section-title a { @@ -3172,6 +3344,10 @@ body { margin: 8px 0 0; color: #777; line-height: 1.6; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; + overflow: hidden; } .m-qualification-card > img { position: absolute; diff --git a/web/src/mobile/views/CompanyQualificationView.vue b/web/src/mobile/views/CompanyQualificationView.vue index 2fbcf3b..1370a21 100644 --- a/web/src/mobile/views/CompanyQualificationView.vue +++ b/web/src/mobile/views/CompanyQualificationView.vue @@ -46,11 +46,21 @@ const loading = ref(true); const error = ref(''); const fallback = '我们致力于通过高效的供应链和优质的服务为用户带来更多实惠与更美好的生活乐趣,让品质好物走进更多人的生活。'; - const paragraphs = computed(() => - String(content.value?.body_text || '').split(/\n+/).map((item) => item.trim()).filter(Boolean), + const bodyText = computed(() => String(content.value?.body_text || '').replace(/\s+/g, ' ').trim()); + function sectionText(start: string, end: string) { + const body = bodyText.value; + const startAt = body.indexOf(start); + if (startAt < 0) return ''; + const from = startAt + start.length; + const endAt = body.indexOf(end, from); + return body.slice(from, endAt < 0 ? undefined : endAt).trim().replace(/^[,。:“”\s]+/, ''); + } + const operationText = computed(() => + sectionText('多实惠,多乐趣', '新国潮,新消费') || content.value?.summary || fallback, + ); + const developmentText = computed(() => + sectionText('新国潮,新消费', '合作品牌') || content.value?.summary || fallback, ); - const operationText = computed(() => content.value?.summary || paragraphs.value[0] || fallback); - const developmentText = computed(() => paragraphs.value[1] || paragraphs.value[0] || fallback); async function load() { loading.value = true; diff --git a/web/src/mobile/views/FormView.vue b/web/src/mobile/views/FormView.vue index 2e37b97..e701886 100644 --- a/web/src/mobile/views/FormView.vue +++ b/web/src/mobile/views/FormView.vue @@ -348,7 +348,7 @@ loading.value = true; error.value = ''; const key = String(route.params.key || ''); - if (['recharge_records', 'wallet_ledgers', 'notices'].includes(key)) { + if (['recharge_records', 'wallet_ledgers'].includes(key)) { loading.value = false; return; } diff --git a/web/src/mobile/views/HomeView.vue b/web/src/mobile/views/HomeView.vue index c9fdfb3..d604bbd 100644 --- a/web/src/mobile/views/HomeView.vue +++ b/web/src/mobile/views/HomeView.vue @@ -128,13 +128,13 @@ label: '储入', path: '/app/recharge', icon: mobileAssets.shortcutRecharge, - enabled: home.value?.features?.recharge_enabled !== false, + enabled: home.value?.features?.recharge_enabled === true, }, { label: '取出', path: '/app/profile/withdrawal', icon: mobileAssets.shortcutWithdraw, - enabled: home.value?.features?.withdrawal_enabled !== false, + enabled: home.value?.features?.withdrawal_enabled === true, }, { label: '商城', diff --git a/web/src/mobile/views/NoticesView.vue b/web/src/mobile/views/NoticesView.vue new file mode 100644 index 0000000..f7e05fd --- /dev/null +++ b/web/src/mobile/views/NoticesView.vue @@ -0,0 +1,105 @@ + + + diff --git a/web/src/mobile/views/OrdersView.vue b/web/src/mobile/views/OrdersView.vue index 2e3e717..196b585 100644 --- a/web/src/mobile/views/OrdersView.vue +++ b/web/src/mobile/views/OrdersView.vue @@ -99,46 +99,10 @@ >数量×{{ detail.quantity || 1 }}
截止时间{{ dateTime(detail.deadline_at) }}
规格{{ detail.product?.specification || detail.product?.spec || '--' }} -

任务说明

{{ detail.task?.description || detail.message }}

-
-
提交状态{{ detail.submission.status_text || detail.submission.status }}
-
处理说明{{ detail.submission.review_reason }}
-
提交时间{{ dateTime(detail.submission.submitted_at) }}
-
-
-

提交任务凭证

- - {{ evidence.length }}/2000,附件上传接口开放后可补充图片或 PDF - -
- 联系客服 @@ -160,8 +124,6 @@ const orders = ref([]); const detail = ref(); const stats = ref(); - const evidence = ref(''); - const submitting = ref(false); let listController: AbortController | undefined; const tabs = [ { label: '全部', value: 'all' }, @@ -212,24 +174,6 @@ if (listController === controller) loading.value = false; } } - async function submitTask() { - if (!evidence.value || submitting.value) return; - submitting.value = true; - try { - await request({ - url: `/v1/task-orders/${route.params.id}/submissions`, - method: 'POST', - data: { evidence_text: evidence.value, evidence_attachment_ids: [] }, - }); - window.$message?.success('提交成功,等待审核'); - evidence.value = ''; - await loadDetail(); - } catch (err) { - window.$message?.error(errorText(err)); - } finally { - submitting.value = false; - } - } async function loadDetail() { loading.value = true; error.value = ''; diff --git a/web/src/mobile/views/ProfileView.vue b/web/src/mobile/views/ProfileView.vue index 4889778..0eb64c2 100644 --- a/web/src/mobile/views/ProfileView.vue +++ b/web/src/mobile/views/ProfileView.vue @@ -73,7 +73,7 @@ path: '/app/profile/records?tab=withdrawal', icon: mobileAssets.menuWithdraw, }, - { label: '账户明细', path: '/app/content/wallet_ledgers', icon: mobileAssets.menuLedger }, + { label: '账户明细', path: '/app/profile/ledgers', icon: mobileAssets.menuLedger }, { label: '弹窗明细', path: '/app/content/notices', icon: mobileAssets.menuNotices }, ]; async function load() { diff --git a/web/src/mobile/views/WalletLedgersView.vue b/web/src/mobile/views/WalletLedgersView.vue new file mode 100644 index 0000000..e5d9dee --- /dev/null +++ b/web/src/mobile/views/WalletLedgersView.vue @@ -0,0 +1,105 @@ + + + diff --git a/web/src/router/index.ts b/web/src/router/index.ts index b8f01e5..5d0a0bb 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -124,6 +124,13 @@ const routes: RouteRecordRaw[] = [ component: () => import('../mobile/views/FinanceRecordsView.vue'), meta: { title: '记录' }, }, + { + path: 'profile/ledgers', + alias: '/app/content/wallet_ledgers', + name: 'MobileWalletLedgers', + component: () => import('../mobile/views/WalletLedgersView.vue'), + meta: { title: '账户明细' }, + }, { path: 'profile/password', name: 'MobileLoginPassword', @@ -154,6 +161,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('../mobile/views/PlatformIntroView.vue'), meta: { title: '平台介绍' }, }, + { + path: 'content/notices', + name: 'MobileNotices', + component: () => import('../mobile/views/NoticesView.vue'), + meta: { title: '弹窗明细' }, + }, { path: 'content/:key', name: 'MobileContent',