goliday

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: MIT Imports: 12 Imported by: 0

README

goliday — Chinese Public Holiday API Service

English | 简体中文

ci codecov CodeQL govulncheck Go Reference OpenSSF Scorecard SonarCloud

A holiday lookup service built on Go 1.27: it records the holiday and workday-swap arrangements published by China's State Council in sparse, per-year config files, derives all other dates from standard-library weekend rules, and exposes semantically identical HTTP and gRPC interfaces supporting date-type queries and range statistics at both coarse and fine granularity.

Core Design

  • Two-tier final-state bitmask: DayType (uint8) uses 2 mutually exclusive base bits (1=work, 2=rest) for coarse granularity, plus 3 mutually exclusive adjustment bits (4=festival, 8=adjusted rest, 16=compensating workday) combining into 5 fine-grained values (1/2/6/10/17); | is all-of (conjunction) semantics across all values, and coarse membership is a single bit-and (t & 2 rest, t & 1 work) with no priority disambiguation.
  • Sparse config: one TOML file per year, containing only "adjusted" dates (off marks weekdays turned into rest days, work marks weekends turned into workdays); weekends and plain weekdays are derived from the weekday, never written down — a yearly file is only 20–30 lines and human-auditable.
  • Config-first with strict year validation: for years with a config, the config wins; uncovered dates fall back to Saturday/Sunday checks. Queries touching a year whose config is not loaded return a year_not_loaded error instead of silently falling back, so "not configured" can never be misread as "actually a weekend".
  • Prefix-sum statistics: per-year prefix sums over fine-grained flag counts are built at load time, making statistics an O(years-covered) difference — the /stats endpoint has no span limit.
  • Minimal dependency surface: the core package depends only on github.com/BurntSushi/toml; the gRPC runtime is isolated to service entrypoints and generated-code packages. Zero frameworks, zero databases; configs are loaded once at startup into pure memory.

Quick Start

Requires Go 1.27+.

# Start the server (HTTP :8080, gRPC :50051)
go run ./cmd/goliday-server -addr :8080 -grpc-addr :50051 -config-dir ./configs

# Single-day query (coarse granularity by default)
curl "http://localhost:8080/api/v1/days?date=2026-02-20"
# {"date":"2026-02-20","type":2,"type_label":"rest","total_days":1,"stats":{"holiday":1,"workday":0}}

# Fine granularity: festival day → rest|festival = 6
curl "http://localhost:8080/api/v1/days?date=2026-02-17&detailed=true"
# {"date":"2026-02-17","type":6,"type_label":"rest|festival",...}

# Range statistics (half-open interval)
curl "http://localhost:8080/api/v1/stats?start=2026-02-14&end=2026-02-17"
# {"mode":"range",...,"total_days":3,"stats":{"workday":1,"holiday":2}}

# gRPC (Go client)
conn, _ := grpc.NewClient("localhost:50051",
    grpc.WithTransportCredentials(insecure.NewCredentials()))
client := golidayv1.NewGolidayServiceClient(conn)
resp, _ := client.GetDay(ctx, &golidayv1.GetDayRequest{Date: "2026-02-17", Detailed: true})
// resp.Type == 6, resp.TypeLabel == "rest|festival"

Examples are based on configs/2026.toml (a hypothetical sample, not official). For production use, generate the config from official announcements following the annual config update process.

Docker

Multi-stage build: compiled as a static binary (CGO_ENABLED=0), the runtime image is gcr.io/distroless/static-debian12:nonroot (no shell, no package manager) and contains only the server binary. Year configs are not baked into the image — mount them at runtime.

# Build locally
docker build -t goliday .

# Run: HTTP :8080, gRPC :50051; mount the config directory read-only
docker run -p 8080:8080 -v $PWD/configs:/data:ro goliday -config-dir /data

curl "http://localhost:8080/healthz"
# {"status":"ok","years":[2025,2026]}

Images are published to GHCR by GitHub Actions on every v* tag push (multi-arch linux/amd64 + linux/arm64; pushes to the default branch, PRs and manual runs build for verification only, without publishing):

docker pull ghcr.io/jaycechant/goliday:latest

API Overview

Protocol Endpoint Description
HTTP GET /api/v1/days Single-day / range (half-open) / discrete / mixed-union queries with per-day details; range span ≤366 days
HTTP GET /api/v1/stats Same statistics as /days, without details; no span limit
HTTP GET /healthz Health check, returns loaded years
gRPC GolidayService GetDay / QueryDays / QueryStats, one-to-one with HTTP; standard gRPC health checking also registered

Day-type bitmask (type_label is exactly DayType.String(): legal values look up a static label table, combo labels join two segments with |, illegal values yield invalid; | is all-of semantics across all values, coarse granularity is the base-bit projection):

Value Combination Meaning Value Combination Meaning
1 Work Plain workday 6 FestivalRest Festival rest day
2 Rest Plain weekend 10 AdjustedRestDay Adjusted rest day (ex-weekday)
4 — Adjustment bit: festival 17 AdjustedWorkDay Compensating workday (ex-weekend)
8 — Adjustment bit: adjusted rest
16 — Adjustment bit: compensating work

Coarse granularity is the base bit itself: with detailed=false, type is 1 (work) or 2 (rest); membership is just t & 1 / t & 2.

Fine-grained statistics (detailed=true) use five MECE keys — plain workday (ordinary), plain weekend (weekend), festival rest day (festival), adjusted rest day (adjusted_rest), compensating workday (adjusted_work) — each counting exactly one class of day, so the keys always sum to total_days; for total rest/workday days use the coarse stats.holiday/stats.workday.

Semantics: "festivals" here are public holidays that grant time off (non-rest commemorative days are out of scope); "adjusted rest" is narrow (an ex-weekday turned into rest by arrangement, not the festival day itself — no new holiday), while a festival day always adds one new holiday; whether a festival falls on a weekday or weekend, the fine-grained type is the same rest|festival.

Full contract (parameters, response structures, error codes, gRPC examples, proto regeneration) in docs/API.md (Chinese).

Annual Config Update

Around November each year, after the State Council publishes next year's arrangement:

  1. Generate a draft from the official announcement: go run ./cmd/goliday-tool gen -year 2027 -out configs/2027.toml -file announcement.txt (or use the LLM prompt template in docs/generate_prompt.md);
  2. Validate: go run ./cmd/goliday-tool validate configs/2027.toml;
  3. Spot-check several dates manually, place the file into configs/, restart the service and confirm via /healthz that the year is loaded.

Config format (rationale, field semantics, validation rules, determination algorithm) in docs/CONFIG_FORMAT.md (Chinese); fully annotated example in docs/holiday_config_example.toml.

Architecture & Dependencies

cmd/goliday-server (HTTP + gRPC entry)   cmd/goliday-tool (gen/validate)
        │                                      │
        └────────────► root package goliday (core lib) ◄──┘
     daytype (bitmask) → config (TOML) → store (per-year loading) → calendar (determination/range/stats)

Dependencies are tiered per package: the root package uses only BurntSushi/toml (zero gRPC imports); the gRPC trio is confined to the proto/goliday/v1/ generated-code package and cmd/ subpackages. See docs/ARCHITECTURE.md (Chinese).

Development

go build ./... && go vet ./... && go test -count=1 ./... && gofmt -l .

Test data: testdata/2025.toml (real official plan), testdata/2026.toml (hypothetical sample), testdata/invalid/ (invalid samples).

Documentation Index

Doc Contents
docs/API.md Full HTTP & gRPC contract, bitmask reference, examples (Chinese)
docs/CONFIG_FORMAT.md Config format rationale, sparse-table principles, validation rules, algorithm (Chinese)
docs/ARCHITECTURE.md Directory layout, layering, dependency constraints, data flow, testing layers, quality gates & CI (Chinese)
docs/generate_prompt.md LLM prompt template: official announcement → yearly config (Chinese)
spec/ Requirements spec, task list and acceptance checklist (see AGENTS.md)

Documentation

Overview

Package goliday 提供中国法定节假日与调休工作日的日期类型判定能力。

Package goliday 提供中国法定节假日与调休工作日的日期类型判定能力。

Index

Constants

View Source
const (
	// DayTypeFestivalRest 节日放假日:节日当天(无论落在工作日还是周末)。
	DayTypeFestivalRest = DayTypeRest | DayTypeFestival
	// DayTypeAdjustedRestDay 调休放假日:原工作日被调整为休息(非节日当天)。
	DayTypeAdjustedRestDay = DayTypeRest | DayTypeAdjustedRest
	// DayTypeAdjustedWorkDay 补班上班日:原周末被调整为上班。
	DayTypeAdjustedWorkDay = DayTypeWork | DayTypeAdjustedWork
)

合法细粒度组合值(终态五值,MECE),由基本位与调整位组合而成。

Variables

View Source
var ErrYearNotLoaded = errors.New("年份配置未加载")

ErrYearNotLoaded 查询覆盖了未加载配置的年份。 未加载年份不再回退到系统周休判断,而是返回包装本错误的错误 (errors.Is 可判别),错误消息中包含具体年份。

Functions

This section is empty.

Types

type Adjust

type Adjust struct {
	Off  []time.Time
	Work []time.Time
}

Adjust 描述某年的调休调整(稀疏表):

Off  放假日:原本需要上班(周一~周五)但被调整为休息的日期,含节日当天与调休日;
Work 补班日:原本休息(周六/周日)但被调整为上班的日期。

type Calendar

type Calendar struct {
	// contains filtered or unexported fields
}

Calendar 提供日期类型查询与统计能力。由 Store 一次性构建各年份的 查找索引与组合计数前缀和,构造完成后只读,可被多个 goroutine 并发调用。

func NewCalendar

func NewCalendar(s *Store) *Calendar

NewCalendar 将 Store 中各年份的稀疏配置转换为按日期哈希的快速查找 索引,并为每年构建细粒度组合计数前缀和(供 StatsRange/Stats 差分统计)。

func (*Calendar) HasYear

func (c *Calendar) HasYear(year int) bool

HasYear 报告指定年份的配置是否已加载。

func (*Calendar) IsRest

func (c *Calendar) IsRest(date time.Time) (bool, error)

IsRest 报告 date 是否为放假日(与基本位 Rest 对齐;含普通周休、 节日放假日与调休放假日,不含补班)。

func (*Calendar) IsWork

func (c *Calendar) IsWork(date time.Time) (bool, error)

IsWork 报告 date 是否为上班日(与基本位 Work 对齐)。

func (*Calendar) Query

func (c *Calendar) Query(date time.Time) (DayType, error)

Query 返回 date 的细粒度日期类型。任意时刻均先按其所在日规范化再查询。

判断优先级:节日当天 → FestivalRest(必为放假日);work 命中 → AdjustedWorkDay;off 命中 → AdjustedRestDay;周休回退(周末 → Rest, 否则 Work)。该年未加载配置时返回包装 ErrYearNotLoaded 的错误, 不再回退周休判断。

func (*Calendar) QueryCoarse

func (c *Calendar) QueryCoarse(date time.Time) (DayType, error)

QueryCoarse 返回 date 的粗粒度日期类型(等价于 Query(date).Coarse())。

func (*Calendar) QueryRange

func (c *Calendar) QueryRange(start, end time.Time) ([]Dated, error)

QueryRange 逐日返回左闭右开区间 [start, end) 内每天的细粒度类型。

end 早于或等于 start 时返回空切片(空区间无覆盖年份,不校验加载状态)。 覆盖任一年份未加载时返回包装 ErrYearNotLoaded 的错误。本方法不限制 区间跨度,跨度上限校验由调用方(如 HTTP 层)负责。

func (*Calendar) Stats

func (c *Calendar) Stats(dates []time.Time, detailed bool) (StatsResult, error)

Stats 统计日期集合。dates 视为已去重升序的日期集合(去重与排序由 调用方负责),逐元素计数不去重。

实现上逐日取前缀和的单日差分(prefix[i+1] - prefix[i]),复用与 StatsRange 相同的前缀和数据。任一日期所在年份未加载时返回包装 ErrYearNotLoaded 的错误;空输入直接返回全零结果。

func (*Calendar) StatsRange

func (c *Calendar) StatsRange(start, end time.Time, detailed bool) (StatsResult, error)

StatsRange 统计左闭右开区间 [start, end)(不限跨度)。

基于构造期构建的组合计数前缀和差分:年内 O(1),跨年按 「首年段 + 若干整年段 + 末年段」拆分后逐段差分相加,复杂度 O(覆盖年数)。 覆盖任一年份未加载时返回包装 ErrYearNotLoaded 的错误; end 早于或等于 start 时返回全零结果(空区间无覆盖年份,不校验)。

type Dated

type Dated struct {
	Date time.Time
	Type DayType
}

Dated 区间/列表查询结果中的单日条目:Date 为规范化到 UTC 午夜的日期, Type 为细粒度日期类型。

type DayType

type DayType uint8

DayType 表示某一天的日期类型,采用位掩码(bitmask)编码, 分为「终态双层」:

粗粒度基本位(互斥,恰一个,表达当日最终是否上班):

DayTypeWork 1<<0 上班;单值 1 即普通工作日
DayTypeRest 1<<1 放假;单值 2 即普通周休(未调整时必然为周末)

调整位(互斥,至多一个,依附于基本位):

DayTypeFestival     1<<2 过节:法定节日当天(放假),当日新增法定假期
DayTypeAdjustedRest 1<<3 调休:原工作日被调整为休息(非节日当天),不新增假期
DayTypeAdjustedWork 1<<4 补班:原周末被调整为上班

全部组合值的按位或均为 all-of(合取)语义,不存在 any-of(并集物化值) 语义;粗粒度即基本位投影(t.Coarse())。合法细粒度值全集为 {1, 2, 6, 10, 17},五值 MECE;零值 DayTypeUnknown(0) 表示未定义类型 (非合法取值),可直接作为默认值。

const (
	// DayTypeUnknown 未定义类型(零值/默认值):非合法细粒度取值,
	// 判类恒 false,String() 输出 "unknown"。
	DayTypeUnknown DayType = 0
	// DayTypeWork 上班(粗粒度基本位;单值即普通工作日)。
	DayTypeWork DayType = 1 << 0
	// DayTypeRest 放假(粗粒度基本位;单值即普通周休,未调整时必然为周末)。
	DayTypeRest DayType = 1 << 1
	// DayTypeFestival 过节:法定节日当天(放假),当日新增法定假期。
	DayTypeFestival DayType = 1 << 2
	// DayTypeAdjustedRest 调休:原工作日被调整为休息(非节日当天),不新增假期。
	DayTypeAdjustedRest DayType = 1 << 3
	// DayTypeAdjustedWork 补班:原周末被调整为上班。
	DayTypeAdjustedWork DayType = 1 << 4
)

func (DayType) Adjustment

func (t DayType) Adjustment() DayType

Adjustment 返回该日期类型的调整位投影(调整掩码), 即 t & dayTypeAdjustMask,与 Coarse 相对应;合法值上 结果 ∈ {0, 4, 8, 16}(无调整位时为 0)且幂等, 非法值(如 12,多调整位并存)返回原值本身。

func (DayType) Coarse

func (t DayType) Coarse() DayType

Coarse 返回该日期类型的粗粒度投影(基本位掩码), 即 t & dayTypeCoarseMask;合法值上结果 ∈ {1, 2} 且幂等, 非法值(如 3,同含两个基本位)返回原值本身。

func (DayType) IsAdjustedRestDay

func (t DayType) IsAdjustedRestDay() bool

IsAdjustedRestDay 报告该日是否为调休放假日:合法值且调整位投影等于 DayTypeAdjustedRest;任何非法值均返回 false。

func (DayType) IsAdjustedWorkDay

func (t DayType) IsAdjustedWorkDay() bool

IsAdjustedWorkDay 报告该日是否为补班上班日:合法值且调整位投影等于 DayTypeAdjustedWork;任何非法值均返回 false。

func (DayType) IsFestivalRest

func (t DayType) IsFestivalRest() bool

IsFestivalRest 报告该日是否为节日放假日:合法值且调整位投影等于 DayTypeFestival(与 IsWork/IsRest 同构,先校验再投影判等)。

func (DayType) IsRest

func (t DayType) IsRest() bool

IsRest 报告该日是否为放假日:合法值且基本位投影等于 DayTypeRest。 任何非法值均返回 false。

func (DayType) IsValid

func (t DayType) IsValid() bool

IsValid 报告 t 是否为合法细粒度值({1, 2, 6, 10, 17} 之一)。

func (DayType) IsWork

func (t DayType) IsWork() bool

IsWork 报告该日是否为上班日:合法值且基本位投影等于 DayTypeWork。 任何非法值(3 同含两基本位、5/9 过节调休配上班位等)均返回 false。

func (DayType) String

func (t DayType) String() string

String 返回 DayType 的字符串表示:合法值与零值 Unknown 查静态标签表 dayTypeStrings(按常量显式索引),如 "unknown"、"work"、"rest|festival"、 "work|adjusted_work";任何非法值统一返回 "invalid"——系统不会产生 非法值,输出逐位拼接的伪标签反而暗示有效状态,统一词更诚实且 便于调用方兜底处理(数值本身可用 %d 查看)。

type Festival

type Festival struct {
	Name string
	Date time.Time
}

Festival 描述一个法定节日:名称与节日当天日期。

type StatsResult

type StatsResult struct {
	Total  int
	Coarse map[DayType]int
	Fine   map[DayType]int
}

StatsResult 统计结果:

Total  覆盖天数(区间天数或 len(dates),不做去重);
Coarse 粗粒度计数,键为 DayTypeWork / DayTypeRest;
Fine   细粒度五键 MECE 计数(普通工作日/普通周休/节日放假日/
       调休放假日/补班日),各键之和恒等于 Total;detailed=false 时为 nil。

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store 持有多个年份的稀疏配置。加载完成后只读,读取方法并发安全。

func LoadDir

func LoadDir(dir string) (*Store, error)

LoadDir 从目录加载所有形如 NNNN.toml 的年份配置文件。

忽略子目录、非 toml 文件与非四位数字年份命名的文件;目录中无任何年份文件时 返回空 Store 与 nil 错误(允许空目录)。加载完成后输出已加载年份列表日志。

func (*Store) Get

func (s *Store) Get(year int) *YearConfig

Get 返回指定年份的配置,不存在时返回 nil。

func (*Store) Has

func (s *Store) Has(year int) bool

Has 报告指定年份的配置是否存在。

func (*Store) Years

func (s *Store) Years() []int

Years 返回已加载的年份列表,升序排列。

type YearConfig

type YearConfig struct {
	Year      int
	Name      string
	Festivals []Festival
	Adjust    Adjust
}

YearConfig 描述某一年的节假日稀疏配置。

func LoadYear

func LoadYear(path string) (*YearConfig, error)

LoadYear 读取并解析单个年份配置文件,执行校验后返回 YearConfig。

文件名(去扩展名)须为纯四位数字年份,且与文件内 year 字段一致。 返回的错误信息以文件路径开头,便于定位。

func (*YearConfig) Validate

func (c *YearConfig) Validate() error

Validate 校验 YearConfig 是否符合稀疏表约束,错误信息指明文件内具体原因:

  • 所有日期年份须等于 c.Year;
  • off 中日期须为周一~周五,work 中日期须为周六/周日;
  • off、work 各自无重复,且两者互斥;
  • work 不得包含任何 festival.date(节日当天不得补班);
  • festival.date 为周一~周五时必须在 off 中——节日当天必为放假日, 工作日节日须经 off 落地(否则该日在类型上无合法状态可归, Work|Festival 为非法值);
  • festival.date 之间无重复。

Directories

Path Synopsis
cmd
goliday-server command
gRPC 服务实现:GolidayService 三方法与 grpc 标准健康检查,与 HTTP 同进程。
gRPC 服务实现:GolidayService 三方法与 grpc 标准健康检查,与 HTTP 同进程。
goliday-tool command
Command goliday-tool 提供年份配置的校验(validate)与生成(gen)子命令。
Command goliday-tool 提供年份配置的校验(validate)与生成(gen)子命令。
proto

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL