diff --git a/backend/database/.transform.yaml b/backend/database/.transform.yaml index 984f49f..f62392f 100644 --- a/backend/database/.transform.yaml +++ b/backend/database/.transform.yaml @@ -19,6 +19,19 @@ field_type: tenant_users: role: types.Array[consts.TenantUserRole] status: consts.UserStatus + media_assets: + type: consts.MediaAssetType + status: consts.MediaAssetStatus + contents: + status: consts.ContentStatus + visibility: consts.ContentVisibility + content_assets: + role: consts.ContentAssetRole + content_prices: + currency: consts.Currency + discount_type: consts.DiscountType + content_access: + status: consts.ContentAccessStatus field_relate: users: OwnedTenant: diff --git a/backend/database/migrations/20251217223000_media_contents.sql b/backend/database/migrations/20251217223000_media_contents.sql new file mode 100644 index 0000000..fe1e327 --- /dev/null +++ b/backend/database/migrations/20251217223000_media_contents.sql @@ -0,0 +1,121 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS media_assets( + id bigserial PRIMARY KEY, + tenant_id bigint NOT NULL, + user_id bigint NOT NULL, + type varchar(32) NOT NULL DEFAULT 'video', + status varchar(32) NOT NULL DEFAULT 'uploaded', + provider varchar(64) NOT NULL DEFAULT '', + bucket varchar(128) NOT NULL DEFAULT '', + object_key varchar(512) NOT NULL DEFAULT '', + meta jsonb NOT NULL DEFAULT '{}'::jsonb, + deleted_at timestamptz, + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS ix_media_assets_tenant_id ON media_assets(tenant_id); +CREATE INDEX IF NOT EXISTS ix_media_assets_tenant_user_id ON media_assets(tenant_id, user_id); +CREATE INDEX IF NOT EXISTS ix_media_assets_tenant_status ON media_assets(tenant_id, status); + +CREATE TABLE IF NOT EXISTS contents( + id bigserial PRIMARY KEY, + tenant_id bigint NOT NULL, + user_id bigint NOT NULL, + title varchar(255) NOT NULL DEFAULT '', + description text NOT NULL DEFAULT '', + status varchar(32) NOT NULL DEFAULT 'draft', + visibility varchar(32) NOT NULL DEFAULT 'tenant_only', + preview_seconds int NOT NULL DEFAULT 60, + preview_downloadable boolean NOT NULL DEFAULT false, + published_at timestamptz, + deleted_at timestamptz, + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS ix_contents_tenant_id ON contents(tenant_id); +CREATE INDEX IF NOT EXISTS ix_contents_tenant_user_id ON contents(tenant_id, user_id); +CREATE INDEX IF NOT EXISTS ix_contents_tenant_status ON contents(tenant_id, status); +CREATE INDEX IF NOT EXISTS ix_contents_tenant_visibility ON contents(tenant_id, visibility); + +CREATE TABLE IF NOT EXISTS content_assets( + id bigserial PRIMARY KEY, + tenant_id bigint NOT NULL, + user_id bigint NOT NULL, + content_id bigint NOT NULL, + asset_id bigint NOT NULL, + role varchar(32) NOT NULL DEFAULT 'main', + sort int NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW(), + UNIQUE (tenant_id, content_id, asset_id) +); + +CREATE INDEX IF NOT EXISTS ix_content_assets_tenant_content ON content_assets(tenant_id, content_id); +CREATE INDEX IF NOT EXISTS ix_content_assets_tenant_asset ON content_assets(tenant_id, asset_id); +CREATE INDEX IF NOT EXISTS ix_content_assets_tenant_role ON content_assets(tenant_id, content_id, role); + +CREATE TABLE IF NOT EXISTS content_prices( + id bigserial PRIMARY KEY, + tenant_id bigint NOT NULL, + user_id bigint NOT NULL, + content_id bigint NOT NULL, + currency varchar(16) NOT NULL DEFAULT 'CNY', + price_amount bigint NOT NULL DEFAULT 0, + discount_type varchar(16) NOT NULL DEFAULT 'none', + discount_value bigint NOT NULL DEFAULT 0, + discount_start_at timestamptz, + discount_end_at timestamptz, + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW(), + UNIQUE (tenant_id, content_id) +); + +CREATE INDEX IF NOT EXISTS ix_content_prices_tenant_id ON content_prices(tenant_id); + +CREATE TABLE IF NOT EXISTS content_access( + id bigserial PRIMARY KEY, + tenant_id bigint NOT NULL, + user_id bigint NOT NULL, + content_id bigint NOT NULL, + order_id bigint, + status varchar(16) NOT NULL DEFAULT 'active', + revoked_at timestamptz, + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW(), + UNIQUE (tenant_id, user_id, content_id) +); + +CREATE INDEX IF NOT EXISTS ix_content_access_tenant_user ON content_access(tenant_id, user_id); +CREATE INDEX IF NOT EXISTS ix_content_access_tenant_content ON content_access(tenant_id, content_id); + +-- +goose StatementEnd +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS ix_content_access_tenant_content; +DROP INDEX IF EXISTS ix_content_access_tenant_user; +DROP TABLE IF EXISTS content_access; + +DROP INDEX IF EXISTS ix_content_prices_tenant_id; +DROP TABLE IF EXISTS content_prices; + +DROP INDEX IF EXISTS ix_content_assets_tenant_role; +DROP INDEX IF EXISTS ix_content_assets_tenant_asset; +DROP INDEX IF EXISTS ix_content_assets_tenant_content; +DROP TABLE IF EXISTS content_assets; + +DROP INDEX IF EXISTS ix_contents_tenant_visibility; +DROP INDEX IF EXISTS ix_contents_tenant_status; +DROP INDEX IF EXISTS ix_contents_tenant_user_id; +DROP INDEX IF EXISTS ix_contents_tenant_id; +DROP TABLE IF EXISTS contents; + +DROP INDEX IF EXISTS ix_media_assets_tenant_status; +DROP INDEX IF EXISTS ix_media_assets_tenant_user_id; +DROP INDEX IF EXISTS ix_media_assets_tenant_id; +DROP TABLE IF EXISTS media_assets; + +-- +goose StatementEnd + diff --git a/backend/pkg/consts/consts.gen.go b/backend/pkg/consts/consts.gen.go index 990a172..899ee08 100644 --- a/backend/pkg/consts/consts.gen.go +++ b/backend/pkg/consts/consts.gen.go @@ -13,6 +13,1336 @@ import ( "strings" ) +const ( + // ContentAccessStatusActive is a ContentAccessStatus of type active. + ContentAccessStatusActive ContentAccessStatus = "active" + // ContentAccessStatusRevoked is a ContentAccessStatus of type revoked. + ContentAccessStatusRevoked ContentAccessStatus = "revoked" + // ContentAccessStatusExpired is a ContentAccessStatus of type expired. + ContentAccessStatusExpired ContentAccessStatus = "expired" +) + +var ErrInvalidContentAccessStatus = fmt.Errorf("not a valid ContentAccessStatus, try [%s]", strings.Join(_ContentAccessStatusNames, ", ")) + +var _ContentAccessStatusNames = []string{ + string(ContentAccessStatusActive), + string(ContentAccessStatusRevoked), + string(ContentAccessStatusExpired), +} + +// ContentAccessStatusNames returns a list of possible string values of ContentAccessStatus. +func ContentAccessStatusNames() []string { + tmp := make([]string, len(_ContentAccessStatusNames)) + copy(tmp, _ContentAccessStatusNames) + return tmp +} + +// ContentAccessStatusValues returns a list of the values for ContentAccessStatus +func ContentAccessStatusValues() []ContentAccessStatus { + return []ContentAccessStatus{ + ContentAccessStatusActive, + ContentAccessStatusRevoked, + ContentAccessStatusExpired, + } +} + +// String implements the Stringer interface. +func (x ContentAccessStatus) String() string { + return string(x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x ContentAccessStatus) IsValid() bool { + _, err := ParseContentAccessStatus(string(x)) + return err == nil +} + +var _ContentAccessStatusValue = map[string]ContentAccessStatus{ + "active": ContentAccessStatusActive, + "revoked": ContentAccessStatusRevoked, + "expired": ContentAccessStatusExpired, +} + +// ParseContentAccessStatus attempts to convert a string to a ContentAccessStatus. +func ParseContentAccessStatus(name string) (ContentAccessStatus, error) { + if x, ok := _ContentAccessStatusValue[name]; ok { + return x, nil + } + return ContentAccessStatus(""), fmt.Errorf("%s is %w", name, ErrInvalidContentAccessStatus) +} + +var errContentAccessStatusNilPtr = errors.New("value pointer is nil") // one per type for package clashes + +// Scan implements the Scanner interface. +func (x *ContentAccessStatus) Scan(value interface{}) (err error) { + if value == nil { + *x = ContentAccessStatus("") + return + } + + // A wider range of scannable types. + // driver.Value values at the top of the list for expediency + switch v := value.(type) { + case string: + *x, err = ParseContentAccessStatus(v) + case []byte: + *x, err = ParseContentAccessStatus(string(v)) + case ContentAccessStatus: + *x = v + case *ContentAccessStatus: + if v == nil { + return errContentAccessStatusNilPtr + } + *x = *v + case *string: + if v == nil { + return errContentAccessStatusNilPtr + } + *x, err = ParseContentAccessStatus(*v) + default: + return errors.New("invalid type for ContentAccessStatus") + } + + return +} + +// Value implements the driver Valuer interface. +func (x ContentAccessStatus) Value() (driver.Value, error) { + return x.String(), nil +} + +// Set implements the Golang flag.Value interface func. +func (x *ContentAccessStatus) Set(val string) error { + v, err := ParseContentAccessStatus(val) + *x = v + return err +} + +// Get implements the Golang flag.Getter interface func. +func (x *ContentAccessStatus) Get() interface{} { + return *x +} + +// Type implements the github.com/spf13/pFlag Value interface. +func (x *ContentAccessStatus) Type() string { + return "ContentAccessStatus" +} + +type NullContentAccessStatus struct { + ContentAccessStatus ContentAccessStatus + Valid bool +} + +func NewNullContentAccessStatus(val interface{}) (x NullContentAccessStatus) { + err := x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + _ = err // make any errcheck linters happy + return +} + +// Scan implements the Scanner interface. +func (x *NullContentAccessStatus) Scan(value interface{}) (err error) { + if value == nil { + x.ContentAccessStatus, x.Valid = ContentAccessStatus(""), false + return + } + + err = x.ContentAccessStatus.Scan(value) + x.Valid = (err == nil) + return +} + +// Value implements the driver Valuer interface. +func (x NullContentAccessStatus) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + // driver.Value accepts int64 for int values. + return string(x.ContentAccessStatus), nil +} + +type NullContentAccessStatusStr struct { + NullContentAccessStatus +} + +func NewNullContentAccessStatusStr(val interface{}) (x NullContentAccessStatusStr) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Value implements the driver Valuer interface. +func (x NullContentAccessStatusStr) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + return x.ContentAccessStatus.String(), nil +} + +const ( + // ContentAssetRoleMain is a ContentAssetRole of type main. + ContentAssetRoleMain ContentAssetRole = "main" + // ContentAssetRoleCover is a ContentAssetRole of type cover. + ContentAssetRoleCover ContentAssetRole = "cover" + // ContentAssetRolePreview is a ContentAssetRole of type preview. + ContentAssetRolePreview ContentAssetRole = "preview" +) + +var ErrInvalidContentAssetRole = fmt.Errorf("not a valid ContentAssetRole, try [%s]", strings.Join(_ContentAssetRoleNames, ", ")) + +var _ContentAssetRoleNames = []string{ + string(ContentAssetRoleMain), + string(ContentAssetRoleCover), + string(ContentAssetRolePreview), +} + +// ContentAssetRoleNames returns a list of possible string values of ContentAssetRole. +func ContentAssetRoleNames() []string { + tmp := make([]string, len(_ContentAssetRoleNames)) + copy(tmp, _ContentAssetRoleNames) + return tmp +} + +// ContentAssetRoleValues returns a list of the values for ContentAssetRole +func ContentAssetRoleValues() []ContentAssetRole { + return []ContentAssetRole{ + ContentAssetRoleMain, + ContentAssetRoleCover, + ContentAssetRolePreview, + } +} + +// String implements the Stringer interface. +func (x ContentAssetRole) String() string { + return string(x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x ContentAssetRole) IsValid() bool { + _, err := ParseContentAssetRole(string(x)) + return err == nil +} + +var _ContentAssetRoleValue = map[string]ContentAssetRole{ + "main": ContentAssetRoleMain, + "cover": ContentAssetRoleCover, + "preview": ContentAssetRolePreview, +} + +// ParseContentAssetRole attempts to convert a string to a ContentAssetRole. +func ParseContentAssetRole(name string) (ContentAssetRole, error) { + if x, ok := _ContentAssetRoleValue[name]; ok { + return x, nil + } + return ContentAssetRole(""), fmt.Errorf("%s is %w", name, ErrInvalidContentAssetRole) +} + +var errContentAssetRoleNilPtr = errors.New("value pointer is nil") // one per type for package clashes + +// Scan implements the Scanner interface. +func (x *ContentAssetRole) Scan(value interface{}) (err error) { + if value == nil { + *x = ContentAssetRole("") + return + } + + // A wider range of scannable types. + // driver.Value values at the top of the list for expediency + switch v := value.(type) { + case string: + *x, err = ParseContentAssetRole(v) + case []byte: + *x, err = ParseContentAssetRole(string(v)) + case ContentAssetRole: + *x = v + case *ContentAssetRole: + if v == nil { + return errContentAssetRoleNilPtr + } + *x = *v + case *string: + if v == nil { + return errContentAssetRoleNilPtr + } + *x, err = ParseContentAssetRole(*v) + default: + return errors.New("invalid type for ContentAssetRole") + } + + return +} + +// Value implements the driver Valuer interface. +func (x ContentAssetRole) Value() (driver.Value, error) { + return x.String(), nil +} + +// Set implements the Golang flag.Value interface func. +func (x *ContentAssetRole) Set(val string) error { + v, err := ParseContentAssetRole(val) + *x = v + return err +} + +// Get implements the Golang flag.Getter interface func. +func (x *ContentAssetRole) Get() interface{} { + return *x +} + +// Type implements the github.com/spf13/pFlag Value interface. +func (x *ContentAssetRole) Type() string { + return "ContentAssetRole" +} + +type NullContentAssetRole struct { + ContentAssetRole ContentAssetRole + Valid bool +} + +func NewNullContentAssetRole(val interface{}) (x NullContentAssetRole) { + err := x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + _ = err // make any errcheck linters happy + return +} + +// Scan implements the Scanner interface. +func (x *NullContentAssetRole) Scan(value interface{}) (err error) { + if value == nil { + x.ContentAssetRole, x.Valid = ContentAssetRole(""), false + return + } + + err = x.ContentAssetRole.Scan(value) + x.Valid = (err == nil) + return +} + +// Value implements the driver Valuer interface. +func (x NullContentAssetRole) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + // driver.Value accepts int64 for int values. + return string(x.ContentAssetRole), nil +} + +type NullContentAssetRoleStr struct { + NullContentAssetRole +} + +func NewNullContentAssetRoleStr(val interface{}) (x NullContentAssetRoleStr) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Value implements the driver Valuer interface. +func (x NullContentAssetRoleStr) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + return x.ContentAssetRole.String(), nil +} + +const ( + // ContentStatusDraft is a ContentStatus of type draft. + ContentStatusDraft ContentStatus = "draft" + // ContentStatusReviewing is a ContentStatus of type reviewing. + ContentStatusReviewing ContentStatus = "reviewing" + // ContentStatusPublished is a ContentStatus of type published. + ContentStatusPublished ContentStatus = "published" + // ContentStatusUnpublished is a ContentStatus of type unpublished. + ContentStatusUnpublished ContentStatus = "unpublished" + // ContentStatusBlocked is a ContentStatus of type blocked. + ContentStatusBlocked ContentStatus = "blocked" +) + +var ErrInvalidContentStatus = fmt.Errorf("not a valid ContentStatus, try [%s]", strings.Join(_ContentStatusNames, ", ")) + +var _ContentStatusNames = []string{ + string(ContentStatusDraft), + string(ContentStatusReviewing), + string(ContentStatusPublished), + string(ContentStatusUnpublished), + string(ContentStatusBlocked), +} + +// ContentStatusNames returns a list of possible string values of ContentStatus. +func ContentStatusNames() []string { + tmp := make([]string, len(_ContentStatusNames)) + copy(tmp, _ContentStatusNames) + return tmp +} + +// ContentStatusValues returns a list of the values for ContentStatus +func ContentStatusValues() []ContentStatus { + return []ContentStatus{ + ContentStatusDraft, + ContentStatusReviewing, + ContentStatusPublished, + ContentStatusUnpublished, + ContentStatusBlocked, + } +} + +// String implements the Stringer interface. +func (x ContentStatus) String() string { + return string(x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x ContentStatus) IsValid() bool { + _, err := ParseContentStatus(string(x)) + return err == nil +} + +var _ContentStatusValue = map[string]ContentStatus{ + "draft": ContentStatusDraft, + "reviewing": ContentStatusReviewing, + "published": ContentStatusPublished, + "unpublished": ContentStatusUnpublished, + "blocked": ContentStatusBlocked, +} + +// ParseContentStatus attempts to convert a string to a ContentStatus. +func ParseContentStatus(name string) (ContentStatus, error) { + if x, ok := _ContentStatusValue[name]; ok { + return x, nil + } + return ContentStatus(""), fmt.Errorf("%s is %w", name, ErrInvalidContentStatus) +} + +var errContentStatusNilPtr = errors.New("value pointer is nil") // one per type for package clashes + +// Scan implements the Scanner interface. +func (x *ContentStatus) Scan(value interface{}) (err error) { + if value == nil { + *x = ContentStatus("") + return + } + + // A wider range of scannable types. + // driver.Value values at the top of the list for expediency + switch v := value.(type) { + case string: + *x, err = ParseContentStatus(v) + case []byte: + *x, err = ParseContentStatus(string(v)) + case ContentStatus: + *x = v + case *ContentStatus: + if v == nil { + return errContentStatusNilPtr + } + *x = *v + case *string: + if v == nil { + return errContentStatusNilPtr + } + *x, err = ParseContentStatus(*v) + default: + return errors.New("invalid type for ContentStatus") + } + + return +} + +// Value implements the driver Valuer interface. +func (x ContentStatus) Value() (driver.Value, error) { + return x.String(), nil +} + +// Set implements the Golang flag.Value interface func. +func (x *ContentStatus) Set(val string) error { + v, err := ParseContentStatus(val) + *x = v + return err +} + +// Get implements the Golang flag.Getter interface func. +func (x *ContentStatus) Get() interface{} { + return *x +} + +// Type implements the github.com/spf13/pFlag Value interface. +func (x *ContentStatus) Type() string { + return "ContentStatus" +} + +type NullContentStatus struct { + ContentStatus ContentStatus + Valid bool +} + +func NewNullContentStatus(val interface{}) (x NullContentStatus) { + err := x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + _ = err // make any errcheck linters happy + return +} + +// Scan implements the Scanner interface. +func (x *NullContentStatus) Scan(value interface{}) (err error) { + if value == nil { + x.ContentStatus, x.Valid = ContentStatus(""), false + return + } + + err = x.ContentStatus.Scan(value) + x.Valid = (err == nil) + return +} + +// Value implements the driver Valuer interface. +func (x NullContentStatus) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + // driver.Value accepts int64 for int values. + return string(x.ContentStatus), nil +} + +type NullContentStatusStr struct { + NullContentStatus +} + +func NewNullContentStatusStr(val interface{}) (x NullContentStatusStr) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Value implements the driver Valuer interface. +func (x NullContentStatusStr) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + return x.ContentStatus.String(), nil +} + +const ( + // ContentVisibilityPublic is a ContentVisibility of type public. + ContentVisibilityPublic ContentVisibility = "public" + // ContentVisibilityTenantOnly is a ContentVisibility of type tenant_only. + ContentVisibilityTenantOnly ContentVisibility = "tenant_only" + // ContentVisibilityPrivate is a ContentVisibility of type private. + ContentVisibilityPrivate ContentVisibility = "private" +) + +var ErrInvalidContentVisibility = fmt.Errorf("not a valid ContentVisibility, try [%s]", strings.Join(_ContentVisibilityNames, ", ")) + +var _ContentVisibilityNames = []string{ + string(ContentVisibilityPublic), + string(ContentVisibilityTenantOnly), + string(ContentVisibilityPrivate), +} + +// ContentVisibilityNames returns a list of possible string values of ContentVisibility. +func ContentVisibilityNames() []string { + tmp := make([]string, len(_ContentVisibilityNames)) + copy(tmp, _ContentVisibilityNames) + return tmp +} + +// ContentVisibilityValues returns a list of the values for ContentVisibility +func ContentVisibilityValues() []ContentVisibility { + return []ContentVisibility{ + ContentVisibilityPublic, + ContentVisibilityTenantOnly, + ContentVisibilityPrivate, + } +} + +// String implements the Stringer interface. +func (x ContentVisibility) String() string { + return string(x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x ContentVisibility) IsValid() bool { + _, err := ParseContentVisibility(string(x)) + return err == nil +} + +var _ContentVisibilityValue = map[string]ContentVisibility{ + "public": ContentVisibilityPublic, + "tenant_only": ContentVisibilityTenantOnly, + "private": ContentVisibilityPrivate, +} + +// ParseContentVisibility attempts to convert a string to a ContentVisibility. +func ParseContentVisibility(name string) (ContentVisibility, error) { + if x, ok := _ContentVisibilityValue[name]; ok { + return x, nil + } + return ContentVisibility(""), fmt.Errorf("%s is %w", name, ErrInvalidContentVisibility) +} + +var errContentVisibilityNilPtr = errors.New("value pointer is nil") // one per type for package clashes + +// Scan implements the Scanner interface. +func (x *ContentVisibility) Scan(value interface{}) (err error) { + if value == nil { + *x = ContentVisibility("") + return + } + + // A wider range of scannable types. + // driver.Value values at the top of the list for expediency + switch v := value.(type) { + case string: + *x, err = ParseContentVisibility(v) + case []byte: + *x, err = ParseContentVisibility(string(v)) + case ContentVisibility: + *x = v + case *ContentVisibility: + if v == nil { + return errContentVisibilityNilPtr + } + *x = *v + case *string: + if v == nil { + return errContentVisibilityNilPtr + } + *x, err = ParseContentVisibility(*v) + default: + return errors.New("invalid type for ContentVisibility") + } + + return +} + +// Value implements the driver Valuer interface. +func (x ContentVisibility) Value() (driver.Value, error) { + return x.String(), nil +} + +// Set implements the Golang flag.Value interface func. +func (x *ContentVisibility) Set(val string) error { + v, err := ParseContentVisibility(val) + *x = v + return err +} + +// Get implements the Golang flag.Getter interface func. +func (x *ContentVisibility) Get() interface{} { + return *x +} + +// Type implements the github.com/spf13/pFlag Value interface. +func (x *ContentVisibility) Type() string { + return "ContentVisibility" +} + +type NullContentVisibility struct { + ContentVisibility ContentVisibility + Valid bool +} + +func NewNullContentVisibility(val interface{}) (x NullContentVisibility) { + err := x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + _ = err // make any errcheck linters happy + return +} + +// Scan implements the Scanner interface. +func (x *NullContentVisibility) Scan(value interface{}) (err error) { + if value == nil { + x.ContentVisibility, x.Valid = ContentVisibility(""), false + return + } + + err = x.ContentVisibility.Scan(value) + x.Valid = (err == nil) + return +} + +// Value implements the driver Valuer interface. +func (x NullContentVisibility) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + // driver.Value accepts int64 for int values. + return string(x.ContentVisibility), nil +} + +type NullContentVisibilityStr struct { + NullContentVisibility +} + +func NewNullContentVisibilityStr(val interface{}) (x NullContentVisibilityStr) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Value implements the driver Valuer interface. +func (x NullContentVisibilityStr) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + return x.ContentVisibility.String(), nil +} + +const ( + // CurrencyCNY is a Currency of type CNY. + CurrencyCNY Currency = "CNY" +) + +var ErrInvalidCurrency = fmt.Errorf("not a valid Currency, try [%s]", strings.Join(_CurrencyNames, ", ")) + +var _CurrencyNames = []string{ + string(CurrencyCNY), +} + +// CurrencyNames returns a list of possible string values of Currency. +func CurrencyNames() []string { + tmp := make([]string, len(_CurrencyNames)) + copy(tmp, _CurrencyNames) + return tmp +} + +// CurrencyValues returns a list of the values for Currency +func CurrencyValues() []Currency { + return []Currency{ + CurrencyCNY, + } +} + +// String implements the Stringer interface. +func (x Currency) String() string { + return string(x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x Currency) IsValid() bool { + _, err := ParseCurrency(string(x)) + return err == nil +} + +var _CurrencyValue = map[string]Currency{ + "CNY": CurrencyCNY, +} + +// ParseCurrency attempts to convert a string to a Currency. +func ParseCurrency(name string) (Currency, error) { + if x, ok := _CurrencyValue[name]; ok { + return x, nil + } + return Currency(""), fmt.Errorf("%s is %w", name, ErrInvalidCurrency) +} + +var errCurrencyNilPtr = errors.New("value pointer is nil") // one per type for package clashes + +// Scan implements the Scanner interface. +func (x *Currency) Scan(value interface{}) (err error) { + if value == nil { + *x = Currency("") + return + } + + // A wider range of scannable types. + // driver.Value values at the top of the list for expediency + switch v := value.(type) { + case string: + *x, err = ParseCurrency(v) + case []byte: + *x, err = ParseCurrency(string(v)) + case Currency: + *x = v + case *Currency: + if v == nil { + return errCurrencyNilPtr + } + *x = *v + case *string: + if v == nil { + return errCurrencyNilPtr + } + *x, err = ParseCurrency(*v) + default: + return errors.New("invalid type for Currency") + } + + return +} + +// Value implements the driver Valuer interface. +func (x Currency) Value() (driver.Value, error) { + return x.String(), nil +} + +// Set implements the Golang flag.Value interface func. +func (x *Currency) Set(val string) error { + v, err := ParseCurrency(val) + *x = v + return err +} + +// Get implements the Golang flag.Getter interface func. +func (x *Currency) Get() interface{} { + return *x +} + +// Type implements the github.com/spf13/pFlag Value interface. +func (x *Currency) Type() string { + return "Currency" +} + +type NullCurrency struct { + Currency Currency + Valid bool +} + +func NewNullCurrency(val interface{}) (x NullCurrency) { + err := x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + _ = err // make any errcheck linters happy + return +} + +// Scan implements the Scanner interface. +func (x *NullCurrency) Scan(value interface{}) (err error) { + if value == nil { + x.Currency, x.Valid = Currency(""), false + return + } + + err = x.Currency.Scan(value) + x.Valid = (err == nil) + return +} + +// Value implements the driver Valuer interface. +func (x NullCurrency) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + // driver.Value accepts int64 for int values. + return string(x.Currency), nil +} + +type NullCurrencyStr struct { + NullCurrency +} + +func NewNullCurrencyStr(val interface{}) (x NullCurrencyStr) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Value implements the driver Valuer interface. +func (x NullCurrencyStr) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + return x.Currency.String(), nil +} + +const ( + // DiscountTypeNone is a DiscountType of type none. + DiscountTypeNone DiscountType = "none" + // DiscountTypePercent is a DiscountType of type percent. + DiscountTypePercent DiscountType = "percent" + // DiscountTypeAmount is a DiscountType of type amount. + DiscountTypeAmount DiscountType = "amount" +) + +var ErrInvalidDiscountType = fmt.Errorf("not a valid DiscountType, try [%s]", strings.Join(_DiscountTypeNames, ", ")) + +var _DiscountTypeNames = []string{ + string(DiscountTypeNone), + string(DiscountTypePercent), + string(DiscountTypeAmount), +} + +// DiscountTypeNames returns a list of possible string values of DiscountType. +func DiscountTypeNames() []string { + tmp := make([]string, len(_DiscountTypeNames)) + copy(tmp, _DiscountTypeNames) + return tmp +} + +// DiscountTypeValues returns a list of the values for DiscountType +func DiscountTypeValues() []DiscountType { + return []DiscountType{ + DiscountTypeNone, + DiscountTypePercent, + DiscountTypeAmount, + } +} + +// String implements the Stringer interface. +func (x DiscountType) String() string { + return string(x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x DiscountType) IsValid() bool { + _, err := ParseDiscountType(string(x)) + return err == nil +} + +var _DiscountTypeValue = map[string]DiscountType{ + "none": DiscountTypeNone, + "percent": DiscountTypePercent, + "amount": DiscountTypeAmount, +} + +// ParseDiscountType attempts to convert a string to a DiscountType. +func ParseDiscountType(name string) (DiscountType, error) { + if x, ok := _DiscountTypeValue[name]; ok { + return x, nil + } + return DiscountType(""), fmt.Errorf("%s is %w", name, ErrInvalidDiscountType) +} + +var errDiscountTypeNilPtr = errors.New("value pointer is nil") // one per type for package clashes + +// Scan implements the Scanner interface. +func (x *DiscountType) Scan(value interface{}) (err error) { + if value == nil { + *x = DiscountType("") + return + } + + // A wider range of scannable types. + // driver.Value values at the top of the list for expediency + switch v := value.(type) { + case string: + *x, err = ParseDiscountType(v) + case []byte: + *x, err = ParseDiscountType(string(v)) + case DiscountType: + *x = v + case *DiscountType: + if v == nil { + return errDiscountTypeNilPtr + } + *x = *v + case *string: + if v == nil { + return errDiscountTypeNilPtr + } + *x, err = ParseDiscountType(*v) + default: + return errors.New("invalid type for DiscountType") + } + + return +} + +// Value implements the driver Valuer interface. +func (x DiscountType) Value() (driver.Value, error) { + return x.String(), nil +} + +// Set implements the Golang flag.Value interface func. +func (x *DiscountType) Set(val string) error { + v, err := ParseDiscountType(val) + *x = v + return err +} + +// Get implements the Golang flag.Getter interface func. +func (x *DiscountType) Get() interface{} { + return *x +} + +// Type implements the github.com/spf13/pFlag Value interface. +func (x *DiscountType) Type() string { + return "DiscountType" +} + +type NullDiscountType struct { + DiscountType DiscountType + Valid bool +} + +func NewNullDiscountType(val interface{}) (x NullDiscountType) { + err := x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + _ = err // make any errcheck linters happy + return +} + +// Scan implements the Scanner interface. +func (x *NullDiscountType) Scan(value interface{}) (err error) { + if value == nil { + x.DiscountType, x.Valid = DiscountType(""), false + return + } + + err = x.DiscountType.Scan(value) + x.Valid = (err == nil) + return +} + +// Value implements the driver Valuer interface. +func (x NullDiscountType) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + // driver.Value accepts int64 for int values. + return string(x.DiscountType), nil +} + +type NullDiscountTypeStr struct { + NullDiscountType +} + +func NewNullDiscountTypeStr(val interface{}) (x NullDiscountTypeStr) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Value implements the driver Valuer interface. +func (x NullDiscountTypeStr) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + return x.DiscountType.String(), nil +} + +const ( + // MediaAssetStatusUploaded is a MediaAssetStatus of type uploaded. + MediaAssetStatusUploaded MediaAssetStatus = "uploaded" + // MediaAssetStatusProcessing is a MediaAssetStatus of type processing. + MediaAssetStatusProcessing MediaAssetStatus = "processing" + // MediaAssetStatusReady is a MediaAssetStatus of type ready. + MediaAssetStatusReady MediaAssetStatus = "ready" + // MediaAssetStatusFailed is a MediaAssetStatus of type failed. + MediaAssetStatusFailed MediaAssetStatus = "failed" + // MediaAssetStatusDeleted is a MediaAssetStatus of type deleted. + MediaAssetStatusDeleted MediaAssetStatus = "deleted" +) + +var ErrInvalidMediaAssetStatus = fmt.Errorf("not a valid MediaAssetStatus, try [%s]", strings.Join(_MediaAssetStatusNames, ", ")) + +var _MediaAssetStatusNames = []string{ + string(MediaAssetStatusUploaded), + string(MediaAssetStatusProcessing), + string(MediaAssetStatusReady), + string(MediaAssetStatusFailed), + string(MediaAssetStatusDeleted), +} + +// MediaAssetStatusNames returns a list of possible string values of MediaAssetStatus. +func MediaAssetStatusNames() []string { + tmp := make([]string, len(_MediaAssetStatusNames)) + copy(tmp, _MediaAssetStatusNames) + return tmp +} + +// MediaAssetStatusValues returns a list of the values for MediaAssetStatus +func MediaAssetStatusValues() []MediaAssetStatus { + return []MediaAssetStatus{ + MediaAssetStatusUploaded, + MediaAssetStatusProcessing, + MediaAssetStatusReady, + MediaAssetStatusFailed, + MediaAssetStatusDeleted, + } +} + +// String implements the Stringer interface. +func (x MediaAssetStatus) String() string { + return string(x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x MediaAssetStatus) IsValid() bool { + _, err := ParseMediaAssetStatus(string(x)) + return err == nil +} + +var _MediaAssetStatusValue = map[string]MediaAssetStatus{ + "uploaded": MediaAssetStatusUploaded, + "processing": MediaAssetStatusProcessing, + "ready": MediaAssetStatusReady, + "failed": MediaAssetStatusFailed, + "deleted": MediaAssetStatusDeleted, +} + +// ParseMediaAssetStatus attempts to convert a string to a MediaAssetStatus. +func ParseMediaAssetStatus(name string) (MediaAssetStatus, error) { + if x, ok := _MediaAssetStatusValue[name]; ok { + return x, nil + } + return MediaAssetStatus(""), fmt.Errorf("%s is %w", name, ErrInvalidMediaAssetStatus) +} + +var errMediaAssetStatusNilPtr = errors.New("value pointer is nil") // one per type for package clashes + +// Scan implements the Scanner interface. +func (x *MediaAssetStatus) Scan(value interface{}) (err error) { + if value == nil { + *x = MediaAssetStatus("") + return + } + + // A wider range of scannable types. + // driver.Value values at the top of the list for expediency + switch v := value.(type) { + case string: + *x, err = ParseMediaAssetStatus(v) + case []byte: + *x, err = ParseMediaAssetStatus(string(v)) + case MediaAssetStatus: + *x = v + case *MediaAssetStatus: + if v == nil { + return errMediaAssetStatusNilPtr + } + *x = *v + case *string: + if v == nil { + return errMediaAssetStatusNilPtr + } + *x, err = ParseMediaAssetStatus(*v) + default: + return errors.New("invalid type for MediaAssetStatus") + } + + return +} + +// Value implements the driver Valuer interface. +func (x MediaAssetStatus) Value() (driver.Value, error) { + return x.String(), nil +} + +// Set implements the Golang flag.Value interface func. +func (x *MediaAssetStatus) Set(val string) error { + v, err := ParseMediaAssetStatus(val) + *x = v + return err +} + +// Get implements the Golang flag.Getter interface func. +func (x *MediaAssetStatus) Get() interface{} { + return *x +} + +// Type implements the github.com/spf13/pFlag Value interface. +func (x *MediaAssetStatus) Type() string { + return "MediaAssetStatus" +} + +type NullMediaAssetStatus struct { + MediaAssetStatus MediaAssetStatus + Valid bool +} + +func NewNullMediaAssetStatus(val interface{}) (x NullMediaAssetStatus) { + err := x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + _ = err // make any errcheck linters happy + return +} + +// Scan implements the Scanner interface. +func (x *NullMediaAssetStatus) Scan(value interface{}) (err error) { + if value == nil { + x.MediaAssetStatus, x.Valid = MediaAssetStatus(""), false + return + } + + err = x.MediaAssetStatus.Scan(value) + x.Valid = (err == nil) + return +} + +// Value implements the driver Valuer interface. +func (x NullMediaAssetStatus) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + // driver.Value accepts int64 for int values. + return string(x.MediaAssetStatus), nil +} + +type NullMediaAssetStatusStr struct { + NullMediaAssetStatus +} + +func NewNullMediaAssetStatusStr(val interface{}) (x NullMediaAssetStatusStr) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Value implements the driver Valuer interface. +func (x NullMediaAssetStatusStr) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + return x.MediaAssetStatus.String(), nil +} + +const ( + // MediaAssetTypeVideo is a MediaAssetType of type video. + MediaAssetTypeVideo MediaAssetType = "video" + // MediaAssetTypeAudio is a MediaAssetType of type audio. + MediaAssetTypeAudio MediaAssetType = "audio" + // MediaAssetTypeImage is a MediaAssetType of type image. + MediaAssetTypeImage MediaAssetType = "image" +) + +var ErrInvalidMediaAssetType = fmt.Errorf("not a valid MediaAssetType, try [%s]", strings.Join(_MediaAssetTypeNames, ", ")) + +var _MediaAssetTypeNames = []string{ + string(MediaAssetTypeVideo), + string(MediaAssetTypeAudio), + string(MediaAssetTypeImage), +} + +// MediaAssetTypeNames returns a list of possible string values of MediaAssetType. +func MediaAssetTypeNames() []string { + tmp := make([]string, len(_MediaAssetTypeNames)) + copy(tmp, _MediaAssetTypeNames) + return tmp +} + +// MediaAssetTypeValues returns a list of the values for MediaAssetType +func MediaAssetTypeValues() []MediaAssetType { + return []MediaAssetType{ + MediaAssetTypeVideo, + MediaAssetTypeAudio, + MediaAssetTypeImage, + } +} + +// String implements the Stringer interface. +func (x MediaAssetType) String() string { + return string(x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x MediaAssetType) IsValid() bool { + _, err := ParseMediaAssetType(string(x)) + return err == nil +} + +var _MediaAssetTypeValue = map[string]MediaAssetType{ + "video": MediaAssetTypeVideo, + "audio": MediaAssetTypeAudio, + "image": MediaAssetTypeImage, +} + +// ParseMediaAssetType attempts to convert a string to a MediaAssetType. +func ParseMediaAssetType(name string) (MediaAssetType, error) { + if x, ok := _MediaAssetTypeValue[name]; ok { + return x, nil + } + return MediaAssetType(""), fmt.Errorf("%s is %w", name, ErrInvalidMediaAssetType) +} + +var errMediaAssetTypeNilPtr = errors.New("value pointer is nil") // one per type for package clashes + +// Scan implements the Scanner interface. +func (x *MediaAssetType) Scan(value interface{}) (err error) { + if value == nil { + *x = MediaAssetType("") + return + } + + // A wider range of scannable types. + // driver.Value values at the top of the list for expediency + switch v := value.(type) { + case string: + *x, err = ParseMediaAssetType(v) + case []byte: + *x, err = ParseMediaAssetType(string(v)) + case MediaAssetType: + *x = v + case *MediaAssetType: + if v == nil { + return errMediaAssetTypeNilPtr + } + *x = *v + case *string: + if v == nil { + return errMediaAssetTypeNilPtr + } + *x, err = ParseMediaAssetType(*v) + default: + return errors.New("invalid type for MediaAssetType") + } + + return +} + +// Value implements the driver Valuer interface. +func (x MediaAssetType) Value() (driver.Value, error) { + return x.String(), nil +} + +// Set implements the Golang flag.Value interface func. +func (x *MediaAssetType) Set(val string) error { + v, err := ParseMediaAssetType(val) + *x = v + return err +} + +// Get implements the Golang flag.Getter interface func. +func (x *MediaAssetType) Get() interface{} { + return *x +} + +// Type implements the github.com/spf13/pFlag Value interface. +func (x *MediaAssetType) Type() string { + return "MediaAssetType" +} + +type NullMediaAssetType struct { + MediaAssetType MediaAssetType + Valid bool +} + +func NewNullMediaAssetType(val interface{}) (x NullMediaAssetType) { + err := x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + _ = err // make any errcheck linters happy + return +} + +// Scan implements the Scanner interface. +func (x *NullMediaAssetType) Scan(value interface{}) (err error) { + if value == nil { + x.MediaAssetType, x.Valid = MediaAssetType(""), false + return + } + + err = x.MediaAssetType.Scan(value) + x.Valid = (err == nil) + return +} + +// Value implements the driver Valuer interface. +func (x NullMediaAssetType) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + // driver.Value accepts int64 for int values. + return string(x.MediaAssetType), nil +} + +type NullMediaAssetTypeStr struct { + NullMediaAssetType +} + +func NewNullMediaAssetTypeStr(val interface{}) (x NullMediaAssetTypeStr) { + x.Scan(val) // yes, we ignore this error, it will just be an invalid value. + return +} + +// Value implements the driver Valuer interface. +func (x NullMediaAssetTypeStr) Value() (driver.Value, error) { + if !x.Valid { + return nil, nil + } + return x.MediaAssetType.String(), nil +} + const ( // RoleUser is a Role of type user. RoleUser Role = "user" diff --git a/backend/pkg/consts/consts.go b/backend/pkg/consts/consts.go index efa963d..6f50269 100644 --- a/backend/pkg/consts/consts.go +++ b/backend/pkg/consts/consts.go @@ -52,3 +52,43 @@ func (t TenantStatus) Description() string { // swagger:enum TenantUserRole // ENUM( member, tenant_admin) type TenantUserRole string + +// media_assets + +// swagger:enum MediaAssetType +// ENUM( video, audio, image ) +type MediaAssetType string + +// swagger:enum MediaAssetStatus +// ENUM( uploaded, processing, ready, failed, deleted ) +type MediaAssetStatus string + +// contents + +// swagger:enum ContentStatus +// ENUM( draft, reviewing, published, unpublished, blocked ) +type ContentStatus string + +// swagger:enum ContentVisibility +// ENUM( public, tenant_only, private ) +type ContentVisibility string + +// swagger:enum ContentAssetRole +// ENUM( main, cover, preview ) +type ContentAssetRole string + +// content_prices + +// swagger:enum DiscountType +// ENUM( none, percent, amount ) +type DiscountType string + +// swagger:enum Currency +// ENUM( CNY ) +type Currency string + +// content_access + +// swagger:enum ContentAccessStatus +// ENUM( active, revoked, expired ) +type ContentAccessStatus string diff --git a/backend/specs/spec01.md b/backend/specs/spec01.md new file mode 100644 index 0000000..3a62f39 --- /dev/null +++ b/backend/specs/spec01.md @@ -0,0 +1,435 @@ +# Spec 01:多租户媒体发布平台(余额隔离 / 订单与退款 / 内容定价) + +> 目标:把“同一用户属于多个租户、租户可为用户充值、余额仅能在当前租户消费、租户管理员可查看订单并退款、管理员发布内容可配置价格与折扣”等需求落到可实现的业务规格,作为后续数据模型/API/权限/流程实现依据。 + +## 1. 背景与范围 + +### 1.1 背景 +- 平台支持视频/音频/图片的媒体内容发布与售卖(或付费访问)。 +- 平台面向多租户(Tenant):不同租户之间的数据、资金、内容必须严格隔离。 +- 同一用户(User)可加入多个租户;在每个租户内拥有独立的“租户内余额”用于消费该租户内的业务。 +- 租户管理员(Tenant Admin)能对订单进行查看、发起退款等操作;管理员发布的内容可配置价格与折扣策略。 + +### 1.2 本 spec 覆盖 +- 多租户身份与权限:同一用户多租户归属、租户内角色。 +- 余额体系:租户为用户充值、租户内余额隔离、消费与退款回滚。 +- 商品与订单:内容定价、折扣、下单、扣款、退款、订单审计。 +- 媒体发布:内容/媒体资源的基础生命周期(上传、审核/上架、购买/访问)。 + +### 1.3 明确不做(暂定) +- 广告分发、推荐算法、复杂分账(创作者分成)、第三方支付直连(如微信/支付宝)细节。 +- 版权确权、内容风控/涉政涉黄自动审核体系(仅预留状态与接口)。 +- 跨租户资产迁移、跨租户合并结算。 + +## 2. 角色与核心术语 + +### 2.1 角色(Actors) +- 平台超级管理员(Super Admin):平台级别的租户管理/风控/对账(可选,视项目现状)。 +- 租户管理员(Tenant Admin):租户内运营角色;发布内容、设置价格与折扣、查看订单与退款。 +- 租户成员(Member):属于某租户的普通用户;可消费该租户内容、可发布内容(若租户开放)。 +- 游客/未加入租户用户:只能浏览公开内容(若允许),不可使用租户余额。 + +### 2.2 核心术语 +- Tenant(租户):逻辑隔离域,拥有自己的内容、订单、余额账本规则。 +- TenantUser(租户成员关系):User 在某个 Tenant 下的身份载体(含 role、balance、状态等)。 +- Balance(余额):以 TenantUser 为维度的可用余额;只可在当前 Tenant 内消费与退款回滚。 +- Ledger(账本/流水):所有余额变动必须落到可审计流水(增、减、冻结/解冻、退款)。 +- Content(内容):一条可出售/可访问的媒体内容实体(可关联多媒体资源)。 +- MediaAsset(媒体资源):视频/音频/图片的文件对象(含转码/封面/时长/尺寸等元数据)。 +- Price(价格):内容在某租户内的定价;折扣可作用于价格形成最终成交价。 +- Order(订单):用户在租户内对内容的购买/消费记录;支持退款。 + +## 3. 多租户与权限模型 + +### 3.1 多租户原则 +- 所有业务数据必须带 `tenant_id`(或可推导的 tenant 归属),并在查询/写入时强制校验租户边界。 +- 同一 user 在不同 tenant 下的余额、订单、内容访问权限互不影响。 + +### 3.2 租户内角色(最小集合) +- `member`:默认角色。 +- `tenant_admin`:租户管理员。 + +> 备注:代码侧已有 `TenantUserRole`(member/tenant_admin),可以作为对齐基准。 + +### 3.3 权限矩阵(建议) +- member: + - 可查看本租户可见内容(公开/已购买/订阅等策略见后文)。 + - 可用本租户余额购买内容/消费服务。 + - 可查看自己的订单与余额流水。 +- tenant_admin: + - 拥有 member 的所有权限。 + - 可创建/编辑/下架内容;配置价格与折扣。 + - 可查看租户内订单(按条件检索/导出)。 + - 可发起退款(遵循退款规则/风控规则)。 + - 可为租户内用户充值(如果业务允许“租户给用户发放额度”)。 +- super admin(可选): + - 可查看全平台租户与全局审计(不在本 spec 强制实现,但建议预留)。 + +## 4. 余额体系(Tenant 内隔离) + +### 4.1 账户维度 +- 余额账户 = `TenantUser` 维度(tenant_id + user_id)。 +- `balance_available`(可用余额):可直接消费。 +- `balance_frozen`(冻结余额,可选):用于“待支付/待确认/争议期”等场景,避免并发重复扣款。 + +> 你已确认需要冻结机制(5.B)。当前项目已有 `tenant_users.balance`(bigint),可作为 `balance_available` 的第一版;建议新增 `balance_frozen`(bigint)并配套流水类型 `freeze/unfreeze`。 + +### 4.2 充值(租户为用户充值) +定义:租户向其成员发放/充值额度,用户只能在该租户内使用。 + +规则建议: +- 充值必须产生一条“充值订单”或“余额流水”(建议两者都存在:订单做业务视角,流水做账本视角)。 +- 充值需支持幂等:相同外部业务单号/请求幂等键重复请求,不可重复入账。 +- 充值来源(可枚举): + - `tenant_grant`:租户后台人工/批量发放额度。 + - `user_pay`:用户实际支付购买额度(本期不做,仅预留)。 + +### 4.3 消费(余额扣减) +定义:用户在租户内使用余额购买内容(或支付发布/服务费用)。 + +你已确认“消费=购买租户内付费内容”(1.A),本 spec 将仅覆盖 `content_purchase`,发布收费等其他计费暂不纳入本期范围。 + +关键规则: +- 订单必须绑定 `tenant_id`,扣款只能操作该 `tenant_id` 下的 `TenantUser` 余额。 +- 扣款以“最终成交价”为准,成交价由“基础价 + 折扣/优惠”计算。 +- 余额不足则拒绝下单/支付。 +- 需要防止并发超卖/重复扣款:建议在创建支付时使用冻结余额或基于数据库事务 + 行锁。 + +### 4.4 退款(订单退款回滚余额) +定义:租户管理员可对订单发起退款,退款金额回到原租户余额账户。 + +规则建议: +- 退款必须可追溯:关联原订单、原扣款流水。 +- 支持两种策略(二选一或都做): + 1) 全额退款:仅允许对未消费/未解锁的内容退款。 + 2) 部分退款:按已消费比例/争议裁决退款(需要更复杂的计费与权益计算)。 +- 你已确认仅做“全额退款 + 限时间窗”(4.A): + - 默认规则:`refundable_until = paid_at + 24h`; + - 管理侧强制退款:`tenant_admin` 可忽略时间窗强制退款(必须写明原因并强审计)。 +- 退款结果必须幂等:同一笔退款请求不可重复入账。 +- 退款权限:仅 `tenant_admin`(或更高)可操作;member 只能发起“退款申请”。 + +## 5. 内容与媒体模型 + +### 5.1 MediaAsset(媒体资源) +支持类型: +- video:原始文件 + 转码产物 + 封面 + 时长 + 分辨率 + 编码信息 +- audio:原始文件 + 转码产物 + 时长 + 码率 +- image:原始文件 + 缩略图 + 尺寸 + +最小字段建议: +- `id` +- `tenant_id` +- `user_id` +- `type`(video/audio/image) +- `storage_provider`、`bucket`、`object_key`(或 url) +- `status`(uploaded/processing/ready/failed/deleted) +- `meta`(JSON:时长/尺寸/码率/哈希等) +- `created_at/updated_at` + +### 5.2 Content(内容) +一条内容可以关联 0..N 个媒体资源(例如:视频+封面图+音频)。 + +最小字段建议: +- `id` +- `tenant_id` +- `user_id` +- `title`、`description` +- `status`(draft/reviewing/published/unpublished/blocked) +- `visibility`(public/tenant_only/private;可扩展) +- `preview_seconds`(默认 60) +- `preview_downloadable`(默认 false) +- `published_at` +- `created_at/updated_at` + +## 6. 定价与折扣 + +### 6.1 定价模型(建议) +价格以“最小货币单位”存储(例如分),避免浮点误差: +- `price_amount`(int64,单位分) +- `currency`(本期固定 CNY;多币种为后续扩展) + +### 6.2 折扣模型(建议最小集合) +折扣针对内容的成交价计算,可先实现“单一折扣规则”: +- `discount_type`: + - `none` + - `percent`(如 20% off) + - `amount`(立减) +- `discount_value`:percent(0-100) 或 amount(分) +- `discount_start_at` / `discount_end_at`(可选) + +计算规则: +- percent:`final = price_amount * (100 - percent) / 100` +- amount:`final = max(0, price_amount - amount)` +- 必须记录下单时的“成交快照”(避免事后改价影响历史订单)。 + +### 6.3 谁能设置 +- 仅 `tenant_admin` 可为其发布的内容设置/修改价格与折扣。 +- 如果未来允许“作者发布但管理员定价”,需要在权限上增加“作者/运营”区分。 + +## 7. 订单模型(购买内容) + +### 7.1 订单类型(建议) +为后续扩展预留 `order_type`: +- `content_purchase`:购买内容(本 spec 核心) +- `topup`:充值订单(租户发放/用户支付) +> 你已确认本期只做 1.A + 2.A,因此 `content_purchase` + `topup(tenant_grant)` 为主,`user_pay`/`service_fee` 仅作为未来扩展位保留。 + +### 7.2 订单状态(建议) +以余额支付为例(不接三方): +- `created`:已创建待支付(可选,若立即扣款可跳过) +- `paid`:已扣款/支付成功 +- `refunding`:退款处理中 +- `refunded`:已退款 +- `canceled`:已取消(未扣款) +- `failed`:失败(扣款失败/规则校验失败) + +冻结机制下的状态约束建议: +- 进入 `paid` 前如发生错误(例如订单落库失败),必须执行 `unfreeze` 回滚冻结余额,并将订单标记为 `failed` 或不落库(但需保证幂等返回一致)。 + - 你已确认:订单创建成功但写 `debit_purchase` 失败时,不保留该订单记录;幂等返回“失败 + 已回滚冻结”。 + +### 7.3 订单字段建议 +- `id` +- `tenant_id` +- `buyer_user_id` +- `order_type` +- `amount_original`(原价) +- `amount_discount`(优惠金额) +- `amount_paid`(实付) +- `currency` +- `status` +- `snapshot`(JSON:下单时价格/折扣/内容标题等快照) +- `created_at/paid_at/refunded_at` +> 订单操作者如需审计,建议使用 `operator_user_id`(例如后台代下单/代退款),或在 `snapshot` 中记录。 + +### 7.4 订单明细(OrderItem,建议) +内容购买通常 1 单 1 内容,但用明细便于扩展: +- `order_id` +- `content_id` +- `content_owner_user_id`(可选,用于后续分成/对账) +- `amount_line`(该行实付) +- `snapshot`(内容快照) + +## 8. 余额流水(账本 Ledger) + +### 8.1 设计原则 +- 余额的每一次变化都必须有流水记录,可审计、可对账、可回放。 +- 流水必须带 `tenant_id` 与 `tenant_user_id`(或 tenant_id+user_id)。 +- 所有入账/出账/退款都强制关联“业务单据”(订单/退款单)。 + +### 8.2 流水类型(建议) +- `credit_topup`:充值入账 +- `debit_purchase`:购买扣款 +- `credit_refund`:退款回滚 +- `freeze` / `unfreeze`:冻结/解冻(可选) +- `adjustment`:人工调账(需强审计) + +### 8.3 幂等与一致性 +- 流水表建议使用 `idempotency_key` 或 `biz_ref_type + biz_ref_id` 做唯一约束,防止重复入账。 +- 扣款与订单状态更新必须在一个事务内完成(或使用可靠消息最终一致)。 + +## 9. 关键流程(建议版) + +### 9.1 加入租户 +1) user 通过邀请/申请加入 tenant +2) 创建 `TenantUser(tenant_id,user_id,role=member,balance=0)` +3) tenant_admin 可提升为 `tenant_admin` + +### 9.2 租户为用户充值 +1) tenant_admin 在后台选择 tenant_user、输入金额、填写备注/原因 +2) 创建 `topup` 订单(或 topup 记录) +3) 写入 ledger:`credit_topup` +4) 增加 tenant_user.balance +5) 返回充值结果与可用余额 + +### 9.3 用户购买内容(余额支付) +1) buyer 选择 tenant 下某 content +2) 系统计算成交价(读取当前 price+discount),生成订单快照 +3) 校验余额足够、内容可售、用户在该 tenant 下有效 +4) 冻结余额:写入 ledger:`freeze`,`balance_available -= amount_paid`,`balance_frozen += amount_paid` +5) 创建订单(status=paid 或 created→paid) +6) 扣款落账:写入 ledger:`debit_purchase`,`balance_frozen -= amount_paid`(表示冻结转为最终扣款) +7) 写入“购买权益”(见 9.5) + +失败回滚(必须): +- 若步骤 4 成功而步骤 5/6 失败:必须写入 `unfreeze` 并回滚余额(`balance_available += amount_paid`,`balance_frozen -= amount_paid`),同时保证幂等键再次请求能返回最终一致结果。 +- 若步骤 5 成功但步骤 6 失败:执行 `unfreeze` 回滚后不落库订单,并对该幂等键固定返回“失败+已回滚”(不可重复创建新订单)。 + +### 9.4 租户管理员退款 +1) tenant_admin 选中订单,校验可退款(状态/风控/时间窗) +2) 创建退款记录(可选),订单状态→refunding +3) 写入 ledger:`credit_refund`(金额=退款金额) +4) 增加 tenant_user.balance(可用余额) +5) 订单状态→refunded,记录 refunded_at 与操作者 +6) 收回/标记权益(若需要) + +你已确认退款对权益的处理: +- 退款成功后,将对应 `content_access.status` 置为 `revoked`(立即失效)。 + +### 9.5 购买权益(访问控制) +最小实现建议:记录 `content_access`(tenant_id, content_id, user_id, order_id, status, created_at)。 +- 访问内容时,校验: + - content 是否公开(public)或 + - user 是否拥有 `content_access` 且有效 + +你已确认需要“试看/预览”(3.B),建议增加: +- `content_assets.role=preview`(或单独字段),允许未购买用户访问 preview 资源; +- 正片资源(main)仍需 `content_access` 校验; +- 订单快照中记录“当时预览策略”,避免策略变更导致争议。 + +预览边界建议(最小可用): +- preview 资源必须与 main 资源彻底区分(不同 object_key / 不同转码模板),避免客户端绕过。 +- preview 可以额外加频控/防盗链(后续),但不影响本期的租户隔离与权益校验。 + +你已确认的试看策略: +- 固定时长试看:默认前 `60s`(仅 streaming,不允许下载)。 + +## 10. 查询与后台能力(Tenant Admin) + +### 10.1 订单查询 +筛选条件建议: +- 时间范围(created_at/paid_at) +- buyer_user_id / 用户关键字 +- content_id / 内容标题关键字 +- 订单状态、订单类型 +- 金额范围 + +### 10.2 退款操作 +输入项建议: +- 退款金额(默认=实付) +- 退款原因(枚举 + 备注) +- 是否立即生效(余额体系通常立即入账) + +输出与审计: +- 记录操作者、操作时间、原订单快照、退款快照、对应流水 id + +## 11. 已确认的关键决策(用于锁定实现) + +你已确认: +- 消费=购买租户内付费内容(1.A) +- 充值来源=仅租户后台发放额度(2.A) +- 内容支持试看/预览(3.B):固定时长前 `60s`,不允许下载 +- 退款=全额退款 + 默认时间窗 `paid_at + 24h`,且租户管理侧允许强制退款(需强审计) +- 余额需要冻结机制(5.B),并要求“失败回滚 + 幂等返回一致” +- 金额=仅 CNY(分)(6.A) + +## 12. 里程碑拆分(建议) +- M1:TenantUser 余额隔离 + 充值入账 + 余额支付下单 + 订单查询 + 全额退款。 +- M2:内容发布全链路(media asset/processing 状态)、内容访问权益、折扣生效与订单快照。 +- M3:冻结/部分退款/风控规则/对账导出/批量充值。 + +## 13. 数据模型草案(供对齐) + +> 注:字段名为建议,最终以现有项目的命名/生成器约束为准。金额统一用 `int64`(分)。 + +### 13.1 tenant_users(已存在,建议扩展方向) +- `tenant_id`, `user_id` +- `role`(member/tenant_admin) +- `balance`(可用余额,已存在) +- (可选)`balance_frozen` +- `status`(active/disabled/pending 等) + +### 13.2 media_assets +- `id`, `tenant_id`, `user_id`, `type` +- `status`(uploaded/processing/ready/failed/deleted) +- `provider`, `bucket`, `object_key`(或 `url`) +- `meta`(JSON:hash、duration、width、height、bitrate、codec...) +- `created_at`, `updated_at` + +### 13.3 contents +- `id`, `tenant_id`, `user_id` +- `title`, `description` +- `status`(draft/reviewing/published/unpublished/blocked) +- `visibility`(public/tenant_only/private) +- `published_at`, `created_at`, `updated_at` + +### 13.4 content_assets(内容与媒体关联表) +- `tenant_id`, `content_id`, `asset_id` +- `role`(main/cover/preview 等) +- `sort` + +### 13.5 content_prices(内容定价;或直接放 contents) +- `tenant_id`, `user_id`, `content_id` +- `currency` +- `price_amount` +- `discount_type`, `discount_value` +- `discount_start_at`, `discount_end_at` +- `updated_at` + +### 13.6 orders / order_items +- orders: + - `id`, `tenant_id`, `buyer_user_id`, `order_type`, `status` + - `amount_original`, `amount_discount`, `amount_paid`, `currency` + - `snapshot`(JSON),`idempotency_key`(唯一) + - `created_at`, `paid_at`, `refunded_at` +- order_items: + - `order_id`, `tenant_id`, `content_id` + - `amount_line` + - `snapshot`(JSON) + +### 13.7 balance_ledgers(强烈建议新增) +- `id`, `tenant_id`, `user_id`(或 `tenant_user_id`) +- `direction`(credit/debit) +- `type`(credit_topup/debit_purchase/credit_refund/...) +- `amount`(正数) +- `balance_before`, `balance_after`(可选但强审计) +- `biz_ref_type`, `biz_ref_id`(唯一约束,幂等) +- `operator_user_id`(谁触发:admin/buyer/system) +- `note`, `created_at` + +### 13.8 content_access(购买权益) +- `tenant_id`, `content_id`, `user_id`, `order_id` +- `status`(active/revoked/expired) +- `created_at`, `revoked_at` + +## 14. API 草案(只描述意图,不锁死路径) + +### 14.1 租户侧(Tenant Admin) +- 充值: + - `POST /tenants/:tenant_id/users/:user_id/topup`(amount, note, idempotency_key) +- 订单查询: + - `GET /tenants/:tenant_id/orders`(分页+筛选) + - `GET /tenants/:tenant_id/orders/:id` +- 退款: + - `POST /tenants/:tenant_id/orders/:id/refund`(amount?, reason, idempotency_key) +- 内容管理: + - `POST /tenants/:tenant_id/contents`(草稿) + - `PATCH /tenants/:tenant_id/contents/:id`(编辑/上架/下架) + - `PUT /tenants/:tenant_id/contents/:id/price`(价格+折扣) + +### 14.2 用户侧(Member) +- 浏览内容: + - `GET /tenants/:tenant_id/contents`(可见列表) + - `GET /tenants/:tenant_id/contents/:id` +- 购买: + - `POST /tenants/:tenant_id/orders`(content_id, idempotency_key) +- 我的订单/余额: + - `GET /tenants/:tenant_id/me/orders` + - `GET /tenants/:tenant_id/me/balance` + - `GET /tenants/:tenant_id/me/ledgers` + +## 15. 边界条件与非功能要求(落地时容易踩坑) + +### 15.1 并发与一致性 +- 同一用户并发下单:必须避免“余额被扣成负数”(事务行锁/冻结机制/乐观锁三选一)。 +- 同一幂等键重复请求:返回同一订单/同一结果,不重复扣款/入账。 +- 价格/折扣被修改:历史订单不受影响,必须依赖订单快照。 + +### 15.2 多租户隔离 +- 任何带 `order_id/content_id/asset_id` 的 API,都必须校验其 `tenant_id` 属于当前上下文租户。 +- 后台“按用户查询订单/余额”也必须限定到租户维度,避免跨租户泄露。 + +### 15.3 金额与舍入 +- 金额统一使用整数分;percent 折扣的舍入规则需要固定(建议向下取整),并写入订单快照。 + +### 15.4 审计与追责 +- 充值、退款、调账必须记录 `operator_user_id`、原因、时间、请求来源。 +- 建议为后台敏感操作增加二次确认/权限分级(后续)。 + +### 15.6 试看与禁下载(3.B 已确认) +- 必须从“资源形态”上实现禁下载:preview 仅提供 60s 的独立转码产物,不复用 main 资源。 +- 媒体访问接口仅下发短时效播放凭证/地址(例如签名 URL/Token),避免返回可长期复用的直链。 +- 客户端不提供“下载”入口不构成安全措施,服务端与存储策略必须生效。 + +### 15.5 可观测性 +- 关键链路(下单扣款/退款入账/上传处理)打点日志:tenant_id、user_id、biz_ref、耗时、结果码。