goliday

package module
v0.1.1 Latest Latest
Warning

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

Go to latest
Published: Sep 8, 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+.

# Install the binaries (Go 1.27+)
go install github.com/JayceChant/goliday/cmd/goliday-server@latest
go install github.com/JayceChant/goliday/cmd/goliday-tool@latest

# Start the server (HTTP :8080, gRPC :50051); `go run` also works from a cloned repo
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"

Server flags:

Flag Default Description
-addr :8080 HTTP listen address
-grpc-addr :50051 gRPC listen address; empty string disables gRPC
-config-dir ./configs Year-config directory
-v Print version and exit

configs/ ships 2025 (the real official plan) and 2026 (a hypothetical sample, for testing only). For production use, generate the target year's config from official announcements following the annual config update process, place it in the config directory and restart the service.

The service serves plain HTTP/gRPC with no built-in TLS or authentication — never expose it directly to the public internet; put it behind a reverse proxy or gateway in production.

Use as a Go Library

The root package goliday is the core library (only the TOML parser as dependency, zero gRPC) and can be embedded directly:

import (
    "time"

    "github.com/JayceChant/goliday"
)

store, err := goliday.LoadDir("configs") // one YYYY.toml per year
if err != nil {
    log.Fatal(err)
}
cal := goliday.NewCalendar(store)

day, _ := time.Parse("2006-01-02", "2026-02-17")
t, _ := cal.Query(day)   // fine-grained: t == goliday.DayTypeFestivalRest, t.String() == "rest|festival"
ok, _ := cal.IsWork(day) // is it a workday?
stats, _ := cal.StatsRange(day, day.AddDate(0, 0, 7), true) // range stats (half-open)

Full API reference via the pkg.go.dev badge at the top.

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 your own config directory read-only at runtime (generate the target year via the annual config update process).

# 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). Tags are produced automatically by release-please: commits follow Conventional Commits (already adopted in this repo), and merging a release PR creates the v* tag, GitHub Release and CHANGELOG entry, then publishes the image — no manual tag push needed:

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. Proto definition at proto/goliday/v1/goliday.proto — non-Go clients can generate their own stubs from it

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)——前三级已构建期收敛为单一 adjust 表,查询一次命中。 该年未加载配置时返回包装 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[day] - prefix[day-1]),复用与 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