feat: add global system notices and Beijing order time display
Deploy olons-server production / deploy (push) Successful in 3m12s
Deploy olons-server production / deploy (push) Successful in 3m12s
This commit is contained in:
@@ -45,6 +45,7 @@ func (a *API) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/v1/customer-services/", a.customerServiceResource)
|
||||
mux.HandleFunc("/v1/notices", a.notices)
|
||||
mux.HandleFunc("/v1/notices/popup", a.popupNotice)
|
||||
mux.HandleFunc("/v1/notices/pending", a.pendingNotice)
|
||||
mux.HandleFunc("/v1/notices/", a.noticeResource)
|
||||
mux.HandleFunc("/v1/task-halls", a.taskHalls)
|
||||
mux.HandleFunc("/v1/tasks/eligibility", a.taskEligibility)
|
||||
|
||||
@@ -211,6 +211,27 @@ func (a *API) popupNotice(w http.ResponseWriter, r *http.Request) {
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) pendingNotice(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "private, no-store")
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w, r, http.MethodGet)
|
||||
return
|
||||
}
|
||||
if !a.ensurePortal(w, r) {
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticateMember(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := a.portal.PendingNotice(r.Context(), principal.TenantID, principal.MemberID)
|
||||
if err != nil {
|
||||
writePortalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeSuccess(w, r, result)
|
||||
}
|
||||
|
||||
func (a *API) noticeResource(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/notices/"), "/")
|
||||
parts := strings.Split(path, "/")
|
||||
|
||||
@@ -22,7 +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)
|
||||
LatestPopupNotice(context.Context, int64, int64) (*store.MemberNotice, error)
|
||||
NextUnreadMemberNotice(context.Context, int64, int64) (*store.MemberNotice, error)
|
||||
MarkMemberNoticeRead(context.Context, int64, int64, int64) error
|
||||
MemberNotices(context.Context, int64, int64, int, int) (*store.MemberNoticePage, error)
|
||||
MemberTaskSummary(context.Context, int64, int64) (*store.TaskSummary, error)
|
||||
@@ -220,6 +220,16 @@ func (s *Service) PopupNotice(ctx context.Context, tenantID, memberID int64) (*N
|
||||
SourceType: "managed_content", CreatedAt: row.PublishedAt}, nil
|
||||
}
|
||||
|
||||
func (s *Service) PendingNotice(ctx context.Context, tenantID, memberID int64) (*Notice, error) {
|
||||
row, err := s.repository.NextUnreadMemberNotice(ctx, tenantID, memberID)
|
||||
if err != nil || row == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Notice{ID: row.ID, Title: row.Title, Content: row.Content,
|
||||
SourceType: noticeSourceName(row.SourceType), StartAt: row.StartAt,
|
||||
EndAt: row.EndAt, CreatedAt: row.CreatedAt}, nil
|
||||
}
|
||||
|
||||
func (s *Service) MarkNoticeRead(ctx context.Context, tenantID, memberID, noticeID int64) error {
|
||||
return s.repository.MarkMemberNoticeRead(ctx, tenantID, memberID, noticeID)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func (r fakeRepository) ActiveMemberLevels(context.Context, int64) ([]store.Memb
|
||||
func (r fakeRepository) ActiveMemberNotices(context.Context, int64, int64, int) ([]store.MemberNotice, error) {
|
||||
return r.notices, nil
|
||||
}
|
||||
func (r fakeRepository) LatestPopupNotice(context.Context, int64, int64) (*store.MemberNotice, error) {
|
||||
func (r fakeRepository) NextUnreadMemberNotice(context.Context, int64, int64) (*store.MemberNotice, error) {
|
||||
if len(r.notices) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
+33
-16
@@ -5,10 +5,21 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
const publishStatusPublished = 3
|
||||
|
||||
var noticeBusinessLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
func noticeBusinessNow() string {
|
||||
return formatNoticeBusinessTime(time.Now())
|
||||
}
|
||||
|
||||
func formatNoticeBusinessTime(value time.Time) string {
|
||||
return value.In(noticeBusinessLocation).Format("2006-01-02 15:04:05.999999999")
|
||||
}
|
||||
|
||||
type Banner struct {
|
||||
ID int64 `db:"id"`
|
||||
ImageURL string `db:"image_url"`
|
||||
@@ -154,29 +165,33 @@ func (s *Store) ActiveMemberNotices(ctx context.Context, tenantID, memberID int6
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
rows := make([]MemberNotice, 0)
|
||||
now := noticeBusinessNow()
|
||||
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(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)
|
||||
AND start_at<=? AND end_at>?
|
||||
ORDER BY start_at DESC,id DESC LIMIT ?`, tenantID, memberID, now, now, limit)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (s *Store) LatestPopupNotice(ctx context.Context, tenantID, memberID int64) (*MemberNotice, error) {
|
||||
func (s *Store) NextUnreadMemberNotice(ctx context.Context, tenantID, memberID int64) (*MemberNotice, error) {
|
||||
if s == nil || s.db == nil {
|
||||
return nil, errors.New("store is unavailable")
|
||||
}
|
||||
var row MemberNotice
|
||||
err := s.db.GetContext(ctx, &row, `SELECT id,title,content,source_type,
|
||||
DATE_FORMAT(start_at,'%Y-%m-%dT%H:%i:%sZ') AS start_at,
|
||||
DATE_FORMAT(end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_at,
|
||||
DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at
|
||||
FROM hg_app_member_notice
|
||||
WHERE tenant_id=? AND member_id IN (0,?) AND notice_status=1
|
||||
AND start_at<=UTC_TIMESTAMP(3) AND end_at>UTC_TIMESTAMP(3)
|
||||
ORDER BY start_at DESC,id DESC LIMIT 1`, tenantID, memberID)
|
||||
now := noticeBusinessNow()
|
||||
err := s.db.GetContext(ctx, &row, `SELECT n.id,n.title,n.content,n.source_type,
|
||||
DATE_FORMAT(n.start_at,'%Y-%m-%dT%H:%i:%sZ') AS start_at,
|
||||
DATE_FORMAT(n.end_at,'%Y-%m-%dT%H:%i:%sZ') AS end_at,
|
||||
DATE_FORMAT(n.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at
|
||||
FROM hg_app_member_notice n
|
||||
WHERE n.tenant_id=? AND n.member_id=? AND n.notice_status=1
|
||||
AND n.start_at<=? AND n.end_at>?
|
||||
AND NOT EXISTS (SELECT 1 FROM hg_app_member_notice_read r
|
||||
WHERE r.tenant_id=n.tenant_id AND r.notice_id=n.id AND r.member_id=?)
|
||||
ORDER BY n.start_at ASC,n.id ASC LIMIT 1`, tenantID, memberID, now, now, memberID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -187,13 +202,14 @@ func (s *Store) MarkMemberNoticeRead(ctx context.Context, tenantID, memberID, no
|
||||
if s == nil || s.db == nil {
|
||||
return errors.New("store is unavailable")
|
||||
}
|
||||
now := noticeBusinessNow()
|
||||
result, err := s.db.ExecContext(ctx, `INSERT INTO hg_app_member_notice_read
|
||||
(tenant_id,notice_id,member_id,read_at,created_at)
|
||||
SELECT n.tenant_id,n.id,?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3)
|
||||
SELECT n.tenant_id,n.id,?,?,?
|
||||
FROM hg_app_member_notice n
|
||||
WHERE n.tenant_id=? AND n.id=? AND n.member_id IN (0,?) AND n.notice_status=1
|
||||
AND n.start_at<=UTC_TIMESTAMP(3) AND n.end_at>UTC_TIMESTAMP(3)
|
||||
ON DUPLICATE KEY UPDATE read_at=VALUES(read_at)`, memberID, tenantID, noticeID, memberID)
|
||||
AND n.start_at<=? AND n.end_at>?
|
||||
ON DUPLICATE KEY UPDATE read_at=VALUES(read_at)`, memberID, now, now, tenantID, noticeID, memberID, now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -212,8 +228,9 @@ func (s *Store) MemberNotices(ctx context.Context, tenantID, memberID int64, off
|
||||
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}
|
||||
AND start_at<=? AND end_at>?`
|
||||
now := noticeBusinessNow()
|
||||
args := []any{tenantID, memberID, now, now}
|
||||
var total int64
|
||||
if err := s.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM hg_app_member_notice`+where, args...); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func TestPendingNoticeAudienceAndReadAcknowledgement(t *testing.T) {
|
||||
dsn := os.Getenv("OLONS_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set OLONS_TEST_MYSQL_DSN to run the notice integration test")
|
||||
}
|
||||
db, err := sqlx.Connect("mysql", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
// Connection-local temporary tables shadow the real tables without changing application data.
|
||||
for _, statement := range []string{
|
||||
`CREATE TEMPORARY TABLE hg_app_member_notice (
|
||||
id BIGINT PRIMARY KEY, tenant_id BIGINT, member_id BIGINT,
|
||||
title VARCHAR(120), content TEXT, source_type INT, notice_status INT,
|
||||
start_at DATETIME(3), end_at DATETIME(3), created_at DATETIME(3))`,
|
||||
`CREATE TEMPORARY TABLE hg_app_member_notice_read (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_id BIGINT, notice_id BIGINT,
|
||||
member_id BIGINT, read_at DATETIME(3), created_at DATETIME(3),
|
||||
UNIQUE KEY (tenant_id, notice_id, member_id))`,
|
||||
} {
|
||||
if _, err := db.Exec(statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
for _, notice := range []struct {
|
||||
id, tenantID, memberID int64
|
||||
status int
|
||||
start, end time.Time
|
||||
}{
|
||||
{1, 0, 9, 1, now.Add(-2 * time.Hour), now.Add(time.Hour)},
|
||||
{2, 0, 9, 1, now.Add(-time.Hour), now.Add(time.Hour)},
|
||||
{3, 0, 10, 1, now.Add(-time.Hour), now.Add(time.Hour)},
|
||||
{4, 1, 9, 1, now.Add(-time.Hour), now.Add(time.Hour)},
|
||||
{5, 0, 0, 1, now.Add(-time.Hour), now.Add(time.Hour)},
|
||||
{6, 0, 9, 2, now.Add(-time.Hour), now.Add(time.Hour)},
|
||||
{7, 0, 9, 1, now.Add(-2 * time.Hour), now.Add(-time.Hour)},
|
||||
{8, 0, 9, 1, now.Add(time.Hour), now.Add(2 * time.Hour)},
|
||||
} {
|
||||
_, err := db.Exec(`INSERT INTO hg_app_member_notice
|
||||
(id,tenant_id,member_id,title,content,source_type,notice_status,start_at,end_at,created_at)
|
||||
VALUES (?,?,?,'系统通知','通知内容',1,?,?,?,?)`, notice.id, notice.tenantID, notice.memberID,
|
||||
notice.status, formatNoticeBusinessTime(notice.start), formatNoticeBusinessTime(notice.end), formatNoticeBusinessTime(now))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ctx := context.Background()
|
||||
s := &Store{db: db}
|
||||
for _, expectedID := range []int64{1, 2} {
|
||||
notice, err := s.NextUnreadMemberNotice(ctx, 0, 9)
|
||||
if err != nil || notice == nil || notice.ID != expectedID {
|
||||
t.Fatalf("pending notice = %+v, error = %v, want id %d", notice, err, expectedID)
|
||||
}
|
||||
if err := s.MarkMemberNoticeRead(ctx, 0, 9, expectedID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if notice, err := s.NextUnreadMemberNotice(ctx, 0, 9); err != nil || notice != nil {
|
||||
t.Fatalf("read, expired, revoked, foreign and broadcast notices must not pop up: %+v, %v", notice, err)
|
||||
}
|
||||
for _, forbiddenID := range []int64{3, 4} {
|
||||
if err := s.MarkMemberNoticeRead(ctx, 0, 9, forbiddenID); !errors.Is(err, ErrResourceNotFound) {
|
||||
t.Fatalf("reading foreign notice %d: error = %v", forbiddenID, err)
|
||||
}
|
||||
}
|
||||
if notice, err := s.NextUnreadMemberNotice(ctx, 0, 10); err != nil || notice == nil || notice.ID != 3 {
|
||||
t.Fatalf("other recipient must retain their unread notice: %+v, %v", notice, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFormatNoticeBusinessTimeUsesShanghaiWallClock(t *testing.T) {
|
||||
value := time.Date(2026, 10, 22, 6, 12, 38, 0, time.UTC)
|
||||
if got, want := formatNoticeBusinessTime(value), "2026-10-22 14:12:38"; got != want {
|
||||
t.Fatalf("formatNoticeBusinessTime() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -541,7 +541,7 @@ func orderSelectSQL() string {
|
||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(o.rule_snapshot,'$.deadline_at')),'') AS deadline_at,
|
||||
COALESCE(DATE_FORMAT(o.matched_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS matched_at,
|
||||
COALESCE(DATE_FORMAT(o.completed_at,'%Y-%m-%dT%H:%i:%sZ'),'') AS completed_at,
|
||||
DATE_FORMAT(o.created_at,'%Y-%m-%dT%H:%i:%sZ') AS created_at,
|
||||
` + databaseWallTimeUTC("o.created_at") + ` AS created_at,
|
||||
DATE_FORMAT(o.updated_at,'%Y-%m-%dT%H:%i:%sZ') AS updated_at,
|
||||
COALESCE(t.title,'') AS task_title,COALESCE(t.description,'') AS task_description,
|
||||
COALESCE(t.detail_html,'') AS task_detail_html,COALESCE(s.submission_no,'') AS submission_no,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package store
|
||||
|
||||
// databaseWallTimeUTC normalizes DATETIME values written by CURRENT_TIMESTAMP.
|
||||
// The database session can use UTC or Beijing time independently of the Go process.
|
||||
// Do not use it for fields explicitly written as UTC by application code.
|
||||
func databaseWallTimeUTC(column string) string {
|
||||
return `DATE_FORMAT(TIMESTAMPADD(SECOND,TIMESTAMPDIFF(SECOND,NOW(),UTC_TIMESTAMP()),` + column + `),'%Y-%m-%dT%H:%i:%sZ')`
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func TestOrderCreatedTimeRespectsDatabaseSessionZone(t *testing.T) {
|
||||
dsn := os.Getenv("OLONS_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set OLONS_TEST_MYSQL_DSN to run the time integration test")
|
||||
}
|
||||
db, err := sqlx.Connect("mysql", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if _, err := db.Exec(`CREATE TEMPORARY TABLE order_time_fixture (created_at DATETIME(3))`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct{ zone, wallTime string }{
|
||||
{"+00:00", "2026-10-22 13:06:00"},
|
||||
{"+08:00", "2026-10-22 21:06:00"},
|
||||
} {
|
||||
t.Run(test.zone, func(t *testing.T) {
|
||||
if _, err := db.Exec(`SET SESSION time_zone=?`, test.zone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`DELETE FROM order_time_fixture`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO order_time_fixture (created_at) VALUES (?)`, test.wallTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var actual string
|
||||
if err := db.Get(&actual, `SELECT `+databaseWallTimeUTC("created_at")+` FROM order_time_fixture`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if actual != "2026-10-22T13:06:00Z" {
|
||||
t.Fatalf("normalized timestamp = %q, want 2026-10-22T13:06:00Z", actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -438,8 +438,13 @@ func allowedActions(row store.TaskOrder) []string {
|
||||
switch row.OrderStatus {
|
||||
case 2:
|
||||
var snapshot map[string]any
|
||||
if json.Unmarshal(row.RuleSnapshot, &snapshot) == nil && snapshot["dispatch_mode"] == "manual" {
|
||||
return []string{"submit", "contact_support"}
|
||||
if json.Unmarshal(row.RuleSnapshot, &snapshot) == nil {
|
||||
if snapshot["balance_shortage_pending"] == true {
|
||||
return []string{"contact_support"}
|
||||
}
|
||||
if snapshot["dispatch_mode"] == "manual" {
|
||||
return []string{"submit", "contact_support"}
|
||||
}
|
||||
}
|
||||
return []string{"contact_support"}
|
||||
case 3, 5, 6, 8:
|
||||
|
||||
@@ -170,3 +170,12 @@ func TestOrdersRejectsUnknownStatus(t *testing.T) {
|
||||
t.Fatalf("expected status error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedActionsBalancePendingOrderCannotBeSubmitted(t *testing.T) {
|
||||
row := store.TaskOrder{OrderStatus: 2, RuleSnapshot: json.RawMessage(`{"dispatch_mode":"manual","balance_shortage_pending":true}`)}
|
||||
for _, action := range allowedActions(row) {
|
||||
if action == "submit" {
|
||||
t.Fatal("余额不足待处理订单不应允许提交")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user