feat(vas): direct-link traffic packs (#229)

users.dl_traffic holds a per-user direct-link download allowance in
bytes (-1 = unlimited, the default, so existing installs keep current
behavior). RedirectDirectLink atomically charges the owner's balance
by file size before issuing the signed entity URL and returns
CodeInsufficientTraffic (40094) when exhausted; the conditional update
can never drive the balance negative.

New "traffic" SKU and gift-code type top up the allowance permanently
(duration ignored); unlimited users stay unlimited on purchase/redeem.
CreditService now re-reads the user so post-purchase responses carry
fresh balances, and exposes dl_traffic in CreditResponse.

Admin VAS gains a Traffic product section and traffic gift-code type;
Shop gains a Traffic packs tab; Finance shows remaining allowance.
en+zh locales.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3589/head
Tomáš Dvořák 2 weeks ago committed by GitHub
parent 413fac06f7
commit 6da4305574
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -228,6 +228,7 @@ Order = user-visible value first; each ships with backend + UI + tests.
- [x] Download source typing + torrent bomb guard (upstream #2178 离线下载) — `CreateDownloadTask` distinguishes plain URLs from BitTorrent sources: `src_file` must name a `.torrent`, `magnet:` links auto-pick a BT-capable provider (qBittorrent preferred, aria2 fallback) and fail fast when only non-BT nodes exist; explicit `provider=ytdlp` with a torrent source is rejected; `validateFiles` caps selected files per task at `maxDownloadFiles` (10k) with `queue.CriticalErr` so crafted torrents cannot flood the entity table
- [x] WeChat scan login (upstream #2729 item 2) — `GET /session/wechat/login` redirects to `open.weixin.qq.com/connect/qrconnect` (scope `snsapi_login`, `#wechat_redirect` fragment); callback exchanges the code at `sns/oauth2/access_token` and binds by unionid (openid fallback); shares the single-use SSO state/ticket machinery and `sso_binding` table; provisioned accounts use synthetic `@connect.wechat.local` addresses with nickname from `/sns/userinfo`; account linking via `?link=1` + unbind via the shared provider route; admin UserSession section gains a WeChat accordion (enabled/AppID/AppSecret/register-enabled, callback URL shown); login page + security settings gain WeChat buttons; en+zh locales
- [x] SMS verification-code sign-in + phone binding — generic HTTP SMS gateway (`sms_*` settings: endpoint/method/headers/body template with `{phone}`/`{code}` placeholders, SSRF-guarded outbound call); `users.phone` unique optional column; KV-stored 6-digit codes (5-min TTL, single-use, 60s resend throttle) across `login`/`bind`/`reset` scenes; `POST /session/sms/send` (IP rate-limit + login-CAPTCHA gate) / `POST /session/sms/login` (auto-provisions synthetic `sms_*@sms.local` accounts when enabled, 2FA continuation preserved) / `POST /user/reset_sms` / `PUT|DELETE /user/setting/phone`; masked phone in user settings response; login page gains an SMS phase + reset-via-SMS mode in forgot password, security settings gain a phone-binding section, admin UserSession gains an SMS gateway accordion; en+zh locales
- [x] Direct-link traffic packs (upstream #2178 item 11) — `users.dl_traffic` (bytes, `-1` = unlimited default preserving legacy behavior); `RedirectDirectLink` atomically charges the owner's balance by file size before issuing the signed entity URL (`CodeInsufficientTraffic` = 40094 on exhaustion, balance never goes negative); new `traffic` SKU + gift-code type top up the balance permanently (unlimited users stay unlimited); admin VAS gains a Traffic product section + traffic gift-code type, Shop gains a Traffic packs tab, Finance shows the remaining allowance; en+zh locales
## 6. Phase D — desktop, all platforms

@ -98,6 +98,7 @@ const (
TypePoints Type = "points"
TypeStorage Type = "storage"
TypeGroup Type = "group"
TypeTraffic Type = "traffic"
)
func (_type Type) String() string {
@ -107,7 +108,7 @@ func (_type Type) String() string {
// TypeValidator is a validator for the "type" field enum values. It is called by the builders before save.
func TypeValidator(_type Type) error {
switch _type {
case TypePoints, TypeStorage, TypeGroup:
case TypePoints, TypeStorage, TypeGroup, TypeTraffic:
return nil
default:
return fmt.Errorf("giftcode: invalid enum value for type field: %q", _type)

File diff suppressed because one or more lines are too long

@ -349,7 +349,7 @@ var (
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime"}},
{Name: "deleted_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"mysql": "datetime"}},
{Name: "code", Type: field.TypeString, Unique: true},
{Name: "type", Type: field.TypeEnum, Enums: []string{"points", "storage", "group"}},
{Name: "type", Type: field.TypeEnum, Enums: []string{"points", "storage", "group", "traffic"}},
{Name: "amount", Type: field.TypeInt64},
{Name: "duration", Type: field.TypeInt64, Nullable: true},
{Name: "used_at", Type: field.TypeTime, Nullable: true},
@ -657,7 +657,7 @@ var (
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"mysql": "datetime"}},
{Name: "deleted_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"mysql": "datetime"}},
{Name: "name", Type: field.TypeString},
{Name: "type", Type: field.TypeEnum, Enums: []string{"storage", "group"}},
{Name: "type", Type: field.TypeEnum, Enums: []string{"storage", "group", "traffic"}},
{Name: "amount", Type: field.TypeInt64},
{Name: "duration", Type: field.TypeInt64, Nullable: true},
{Name: "price", Type: field.TypeInt64, Nullable: true},
@ -790,6 +790,7 @@ var (
{Name: "last_login", Type: field.TypeTime, Nullable: true},
{Name: "storage", Type: field.TypeInt64, Default: 0},
{Name: "credits", Type: field.TypeInt64, Default: 0},
{Name: "dl_traffic", Type: field.TypeInt64, Default: -1},
{Name: "two_factor_secret", Type: field.TypeString, Nullable: true},
{Name: "vault_password", Type: field.TypeString, Nullable: true},
{Name: "vault_folder", Type: field.TypeInt, Nullable: true, Default: 0},
@ -806,7 +807,7 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "users_groups_users",
Columns: []*schema.Column{UsersColumns[20]},
Columns: []*schema.Column{UsersColumns[21]},
RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.NoAction,
},

@ -24561,6 +24561,8 @@ type UserMutation struct {
addstorage *int64
credits *int64
addcredits *int64
dl_traffic *int64
adddl_traffic *int64
two_factor_secret *string
vault_password *string
vault_folder *int
@ -25300,6 +25302,62 @@ func (m *UserMutation) ResetCredits() {
m.addcredits = nil
}
// SetDlTraffic sets the "dl_traffic" field.
func (m *UserMutation) SetDlTraffic(i int64) {
m.dl_traffic = &i
m.adddl_traffic = nil
}
// DlTraffic returns the value of the "dl_traffic" field in the mutation.
func (m *UserMutation) DlTraffic() (r int64, exists bool) {
v := m.dl_traffic
if v == nil {
return
}
return *v, true
}
// OldDlTraffic returns the old "dl_traffic" field's value of the User entity.
// If the User object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *UserMutation) OldDlTraffic(ctx context.Context) (v int64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldDlTraffic is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldDlTraffic requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldDlTraffic: %w", err)
}
return oldValue.DlTraffic, nil
}
// AddDlTraffic adds i to the "dl_traffic" field.
func (m *UserMutation) AddDlTraffic(i int64) {
if m.adddl_traffic != nil {
*m.adddl_traffic += i
} else {
m.adddl_traffic = &i
}
}
// AddedDlTraffic returns the value that was added to the "dl_traffic" field in this mutation.
func (m *UserMutation) AddedDlTraffic() (r int64, exists bool) {
v := m.adddl_traffic
if v == nil {
return
}
return *v, true
}
// ResetDlTraffic resets all changes to the "dl_traffic" field.
func (m *UserMutation) ResetDlTraffic() {
m.dl_traffic = nil
m.adddl_traffic = nil
}
// SetTwoFactorSecret sets the "two_factor_secret" field.
func (m *UserMutation) SetTwoFactorSecret(s string) {
m.two_factor_secret = &s
@ -26443,7 +26501,7 @@ func (m *UserMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *UserMutation) Fields() []string {
fields := make([]string, 0, 20)
fields := make([]string, 0, 21)
if m.created_at != nil {
fields = append(fields, user.FieldCreatedAt)
}
@ -26483,6 +26541,9 @@ func (m *UserMutation) Fields() []string {
if m.credits != nil {
fields = append(fields, user.FieldCredits)
}
if m.dl_traffic != nil {
fields = append(fields, user.FieldDlTraffic)
}
if m.two_factor_secret != nil {
fields = append(fields, user.FieldTwoFactorSecret)
}
@ -26538,6 +26599,8 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) {
return m.Storage()
case user.FieldCredits:
return m.Credits()
case user.FieldDlTraffic:
return m.DlTraffic()
case user.FieldTwoFactorSecret:
return m.TwoFactorSecret()
case user.FieldVaultPassword:
@ -26587,6 +26650,8 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er
return m.OldStorage(ctx)
case user.FieldCredits:
return m.OldCredits(ctx)
case user.FieldDlTraffic:
return m.OldDlTraffic(ctx)
case user.FieldTwoFactorSecret:
return m.OldTwoFactorSecret(ctx)
case user.FieldVaultPassword:
@ -26701,6 +26766,13 @@ func (m *UserMutation) SetField(name string, value ent.Value) error {
}
m.SetCredits(v)
return nil
case user.FieldDlTraffic:
v, ok := value.(int64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetDlTraffic(v)
return nil
case user.FieldTwoFactorSecret:
v, ok := value.(string)
if !ok {
@ -26764,6 +26836,9 @@ func (m *UserMutation) AddedFields() []string {
if m.addcredits != nil {
fields = append(fields, user.FieldCredits)
}
if m.adddl_traffic != nil {
fields = append(fields, user.FieldDlTraffic)
}
if m.addvault_folder != nil {
fields = append(fields, user.FieldVaultFolder)
}
@ -26779,6 +26854,8 @@ func (m *UserMutation) AddedField(name string) (ent.Value, bool) {
return m.AddedStorage()
case user.FieldCredits:
return m.AddedCredits()
case user.FieldDlTraffic:
return m.AddedDlTraffic()
case user.FieldVaultFolder:
return m.AddedVaultFolder()
}
@ -26804,6 +26881,13 @@ func (m *UserMutation) AddField(name string, value ent.Value) error {
}
m.AddCredits(v)
return nil
case user.FieldDlTraffic:
v, ok := value.(int64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddDlTraffic(v)
return nil
case user.FieldVaultFolder:
v, ok := value.(int)
if !ok {
@ -26952,6 +27036,9 @@ func (m *UserMutation) ResetField(name string) error {
case user.FieldCredits:
m.ResetCredits()
return nil
case user.FieldDlTraffic:
m.ResetDlTraffic()
return nil
case user.FieldTwoFactorSecret:
m.ResetTwoFactorSecret()
return nil

@ -693,12 +693,16 @@ func init() {
userDescCredits := userFields[9].Descriptor()
// user.DefaultCredits holds the default value on creation for the credits field.
user.DefaultCredits = userDescCredits.Default.(int64)
// userDescDlTraffic is the schema descriptor for dl_traffic field.
userDescDlTraffic := userFields[10].Descriptor()
// user.DefaultDlTraffic holds the default value on creation for the dl_traffic field.
user.DefaultDlTraffic = userDescDlTraffic.Default.(int64)
// userDescVaultFolder is the schema descriptor for vault_folder field.
userDescVaultFolder := userFields[12].Descriptor()
userDescVaultFolder := userFields[13].Descriptor()
// user.DefaultVaultFolder holds the default value on creation for the vault_folder field.
user.DefaultVaultFolder = userDescVaultFolder.Default.(int)
// userDescSettings is the schema descriptor for settings field.
userDescSettings := userFields[15].Descriptor()
userDescSettings := userFields[16].Descriptor()
// user.DefaultSettings holds the default value on creation for the settings field.
user.DefaultSettings = userDescSettings.Default.(*types.UserSetting)
usergrantMixin := schema.UserGrant{}.Mixin()

@ -7,7 +7,8 @@ import (
)
// GiftCode holds the schema definition for admin-generated redemption codes.
// A code grants credits, a storage bonus, or a group upgrade when redeemed.
// A code grants credits, a storage bonus, a direct-link traffic pack, or a
// group upgrade when redeemed.
// Redemption is claimed atomically by updating used_by_id from NULL.
type GiftCode struct {
ent.Schema
@ -19,7 +20,7 @@ func (GiftCode) Fields() []ent.Field {
field.String("code").
Unique(),
field.Enum("type").
Values("points", "storage", "group"),
Values("points", "storage", "group", "traffic"),
field.Int64("amount"),
field.Int64("duration").
Optional(),

@ -6,8 +6,10 @@ import (
)
// Sku holds the schema definition for purchasable products: a storage
// capacity pack (amount = bytes) or a membership upgrade (amount = target
// group id). duration is seconds; 0 means the grant never expires. points
// capacity pack (amount = bytes), a direct-link traffic pack (amount =
// bytes added to dl_traffic), or a membership upgrade (amount = target
// group id). duration is seconds; 0 means the grant never expires. Traffic
// packs apply permanently and ignore duration. points
// is the credit price; NULL means the product cannot be bought with
// points. price is the display cash price in the smallest currency unit —
// cash payment processors are intentionally out of scope.
@ -20,7 +22,7 @@ func (Sku) Fields() []ent.Field {
return []ent.Field{
field.String("name"),
field.Enum("type").
Values("storage", "group"),
Values("storage", "group", "traffic"),
field.Int64("amount"),
field.Int64("duration").
Optional(),

@ -49,6 +49,12 @@ func (User) Fields() []ent.Field {
// credit_txns and applied under the users-row lock.
field.Int64("credits").
Default(0),
// dl_traffic is the remaining direct-link download allowance in
// bytes. -1 means unlimited (the default), preserving pre-feature
// behavior; >=0 is decremented by the entity size on each
// direct-link download.
field.Int64("dl_traffic").
Default(-1),
field.String("two_factor_secret").
Sensitive().
Optional(),

@ -106,6 +106,7 @@ type Type string
const (
TypeStorage Type = "storage"
TypeGroup Type = "group"
TypeTraffic Type = "traffic"
)
func (_type Type) String() string {
@ -115,7 +116,7 @@ func (_type Type) String() string {
// TypeValidator is a validator for the "type" field enum values. It is called by the builders before save.
func TypeValidator(_type Type) error {
switch _type {
case TypeStorage, TypeGroup:
case TypeStorage, TypeGroup, TypeTraffic:
return nil
default:
return fmt.Errorf("sku: invalid enum value for type field: %q", _type)

@ -46,6 +46,8 @@ type User struct {
Storage int64 `json:"storage,omitempty"`
// Credits holds the value of the "credits" field.
Credits int64 `json:"credits,omitempty"`
// DlTraffic holds the value of the "dl_traffic" field.
DlTraffic int64 `json:"dl_traffic,omitempty"`
// TwoFactorSecret holds the value of the "two_factor_secret" field.
TwoFactorSecret string `json:"-"`
// VaultPassword holds the value of the "vault_password" field.
@ -238,7 +240,7 @@ func (*User) scanValues(columns []string) ([]any, error) {
switch columns[i] {
case user.FieldTwoFactorBackupCodes, user.FieldSettings:
values[i] = new([]byte)
case user.FieldID, user.FieldStorage, user.FieldCredits, user.FieldVaultFolder, user.FieldGroupUsers:
case user.FieldID, user.FieldStorage, user.FieldCredits, user.FieldDlTraffic, user.FieldVaultFolder, user.FieldGroupUsers:
values[i] = new(sql.NullInt64)
case user.FieldEmail, user.FieldPhone, user.FieldNick, user.FieldPassword, user.FieldStatus, user.FieldBanReason, user.FieldTwoFactorSecret, user.FieldVaultPassword, user.FieldAvatar:
values[i] = new(sql.NullString)
@ -347,6 +349,12 @@ func (u *User) assignValues(columns []string, values []any) error {
} else if value.Valid {
u.Credits = value.Int64
}
case user.FieldDlTraffic:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field dl_traffic", values[i])
} else if value.Valid {
u.DlTraffic = value.Int64
}
case user.FieldTwoFactorSecret:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field two_factor_secret", values[i])
@ -545,6 +553,9 @@ func (u *User) String() string {
builder.WriteString("credits=")
builder.WriteString(fmt.Sprintf("%v", u.Credits))
builder.WriteString(", ")
builder.WriteString("dl_traffic=")
builder.WriteString(fmt.Sprintf("%v", u.DlTraffic))
builder.WriteString(", ")
builder.WriteString("two_factor_secret=<sensitive>")
builder.WriteString(", ")
builder.WriteString("vault_password=<sensitive>")

@ -43,6 +43,8 @@ const (
FieldStorage = "storage"
// FieldCredits holds the string denoting the credits field in the database.
FieldCredits = "credits"
// FieldDlTraffic holds the string denoting the dl_traffic field in the database.
FieldDlTraffic = "dl_traffic"
// FieldTwoFactorSecret holds the string denoting the two_factor_secret field in the database.
FieldTwoFactorSecret = "two_factor_secret"
// FieldVaultPassword holds the string denoting the vault_password field in the database.
@ -203,6 +205,7 @@ var Columns = []string{
FieldLastLogin,
FieldStorage,
FieldCredits,
FieldDlTraffic,
FieldTwoFactorSecret,
FieldVaultPassword,
FieldVaultFolder,
@ -246,6 +249,8 @@ var (
DefaultStorage int64
// DefaultCredits holds the default value on creation for the "credits" field.
DefaultCredits int64
// DefaultDlTraffic holds the default value on creation for the "dl_traffic" field.
DefaultDlTraffic int64
// DefaultVaultFolder holds the default value on creation for the "vault_folder" field.
DefaultVaultFolder int
// DefaultSettings holds the default value on creation for the "settings" field.
@ -353,6 +358,11 @@ func ByCredits(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldCredits, opts...).ToFunc()
}
// ByDlTraffic orders the results by the dl_traffic field.
func ByDlTraffic(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldDlTraffic, opts...).ToFunc()
}
// ByTwoFactorSecret orders the results by the two_factor_secret field.
func ByTwoFactorSecret(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldTwoFactorSecret, opts...).ToFunc()

@ -115,6 +115,11 @@ func Credits(v int64) predicate.User {
return predicate.User(sql.FieldEQ(FieldCredits, v))
}
// DlTraffic applies equality check predicate on the "dl_traffic" field. It's identical to DlTrafficEQ.
func DlTraffic(v int64) predicate.User {
return predicate.User(sql.FieldEQ(FieldDlTraffic, v))
}
// TwoFactorSecret applies equality check predicate on the "two_factor_secret" field. It's identical to TwoFactorSecretEQ.
func TwoFactorSecret(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldTwoFactorSecret, v))
@ -825,6 +830,46 @@ func CreditsLTE(v int64) predicate.User {
return predicate.User(sql.FieldLTE(FieldCredits, v))
}
// DlTrafficEQ applies the EQ predicate on the "dl_traffic" field.
func DlTrafficEQ(v int64) predicate.User {
return predicate.User(sql.FieldEQ(FieldDlTraffic, v))
}
// DlTrafficNEQ applies the NEQ predicate on the "dl_traffic" field.
func DlTrafficNEQ(v int64) predicate.User {
return predicate.User(sql.FieldNEQ(FieldDlTraffic, v))
}
// DlTrafficIn applies the In predicate on the "dl_traffic" field.
func DlTrafficIn(vs ...int64) predicate.User {
return predicate.User(sql.FieldIn(FieldDlTraffic, vs...))
}
// DlTrafficNotIn applies the NotIn predicate on the "dl_traffic" field.
func DlTrafficNotIn(vs ...int64) predicate.User {
return predicate.User(sql.FieldNotIn(FieldDlTraffic, vs...))
}
// DlTrafficGT applies the GT predicate on the "dl_traffic" field.
func DlTrafficGT(v int64) predicate.User {
return predicate.User(sql.FieldGT(FieldDlTraffic, v))
}
// DlTrafficGTE applies the GTE predicate on the "dl_traffic" field.
func DlTrafficGTE(v int64) predicate.User {
return predicate.User(sql.FieldGTE(FieldDlTraffic, v))
}
// DlTrafficLT applies the LT predicate on the "dl_traffic" field.
func DlTrafficLT(v int64) predicate.User {
return predicate.User(sql.FieldLT(FieldDlTraffic, v))
}
// DlTrafficLTE applies the LTE predicate on the "dl_traffic" field.
func DlTrafficLTE(v int64) predicate.User {
return predicate.User(sql.FieldLTE(FieldDlTraffic, v))
}
// TwoFactorSecretEQ applies the EQ predicate on the "two_factor_secret" field.
func TwoFactorSecretEQ(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldTwoFactorSecret, v))

@ -203,6 +203,20 @@ func (uc *UserCreate) SetNillableCredits(i *int64) *UserCreate {
return uc
}
// SetDlTraffic sets the "dl_traffic" field.
func (uc *UserCreate) SetDlTraffic(i int64) *UserCreate {
uc.mutation.SetDlTraffic(i)
return uc
}
// SetNillableDlTraffic sets the "dl_traffic" field if the given value is not nil.
func (uc *UserCreate) SetNillableDlTraffic(i *int64) *UserCreate {
if i != nil {
uc.SetDlTraffic(*i)
}
return uc
}
// SetTwoFactorSecret sets the "two_factor_secret" field.
func (uc *UserCreate) SetTwoFactorSecret(s string) *UserCreate {
uc.mutation.SetTwoFactorSecret(s)
@ -546,6 +560,10 @@ func (uc *UserCreate) defaults() error {
v := user.DefaultCredits
uc.mutation.SetCredits(v)
}
if _, ok := uc.mutation.DlTraffic(); !ok {
v := user.DefaultDlTraffic
uc.mutation.SetDlTraffic(v)
}
if _, ok := uc.mutation.VaultFolder(); !ok {
v := user.DefaultVaultFolder
uc.mutation.SetVaultFolder(v)
@ -600,6 +618,9 @@ func (uc *UserCreate) check() error {
if _, ok := uc.mutation.Credits(); !ok {
return &ValidationError{Name: "credits", err: errors.New(`ent: missing required field "User.credits"`)}
}
if _, ok := uc.mutation.DlTraffic(); !ok {
return &ValidationError{Name: "dl_traffic", err: errors.New(`ent: missing required field "User.dl_traffic"`)}
}
if _, ok := uc.mutation.GroupUsers(); !ok {
return &ValidationError{Name: "group_users", err: errors.New(`ent: missing required field "User.group_users"`)}
}
@ -692,6 +713,10 @@ func (uc *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) {
_spec.SetField(user.FieldCredits, field.TypeInt64, value)
_node.Credits = value
}
if value, ok := uc.mutation.DlTraffic(); ok {
_spec.SetField(user.FieldDlTraffic, field.TypeInt64, value)
_node.DlTraffic = value
}
if value, ok := uc.mutation.TwoFactorSecret(); ok {
_spec.SetField(user.FieldTwoFactorSecret, field.TypeString, value)
_node.TwoFactorSecret = value
@ -1185,6 +1210,24 @@ func (u *UserUpsert) AddCredits(v int64) *UserUpsert {
return u
}
// SetDlTraffic sets the "dl_traffic" field.
func (u *UserUpsert) SetDlTraffic(v int64) *UserUpsert {
u.Set(user.FieldDlTraffic, v)
return u
}
// UpdateDlTraffic sets the "dl_traffic" field to the value that was provided on create.
func (u *UserUpsert) UpdateDlTraffic() *UserUpsert {
u.SetExcluded(user.FieldDlTraffic)
return u
}
// AddDlTraffic adds v to the "dl_traffic" field.
func (u *UserUpsert) AddDlTraffic(v int64) *UserUpsert {
u.Add(user.FieldDlTraffic, v)
return u
}
// SetTwoFactorSecret sets the "two_factor_secret" field.
func (u *UserUpsert) SetTwoFactorSecret(v string) *UserUpsert {
u.Set(user.FieldTwoFactorSecret, v)
@ -1580,6 +1623,27 @@ func (u *UserUpsertOne) UpdateCredits() *UserUpsertOne {
})
}
// SetDlTraffic sets the "dl_traffic" field.
func (u *UserUpsertOne) SetDlTraffic(v int64) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.SetDlTraffic(v)
})
}
// AddDlTraffic adds v to the "dl_traffic" field.
func (u *UserUpsertOne) AddDlTraffic(v int64) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.AddDlTraffic(v)
})
}
// UpdateDlTraffic sets the "dl_traffic" field to the value that was provided on create.
func (u *UserUpsertOne) UpdateDlTraffic() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.UpdateDlTraffic()
})
}
// SetTwoFactorSecret sets the "two_factor_secret" field.
func (u *UserUpsertOne) SetTwoFactorSecret(v string) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
@ -2167,6 +2231,27 @@ func (u *UserUpsertBulk) UpdateCredits() *UserUpsertBulk {
})
}
// SetDlTraffic sets the "dl_traffic" field.
func (u *UserUpsertBulk) SetDlTraffic(v int64) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.SetDlTraffic(v)
})
}
// AddDlTraffic adds v to the "dl_traffic" field.
func (u *UserUpsertBulk) AddDlTraffic(v int64) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.AddDlTraffic(v)
})
}
// UpdateDlTraffic sets the "dl_traffic" field to the value that was provided on create.
func (u *UserUpsertBulk) UpdateDlTraffic() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.UpdateDlTraffic()
})
}
// SetTwoFactorSecret sets the "two_factor_secret" field.
func (u *UserUpsertBulk) SetTwoFactorSecret(v string) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {

@ -254,6 +254,27 @@ func (uu *UserUpdate) AddCredits(i int64) *UserUpdate {
return uu
}
// SetDlTraffic sets the "dl_traffic" field.
func (uu *UserUpdate) SetDlTraffic(i int64) *UserUpdate {
uu.mutation.ResetDlTraffic()
uu.mutation.SetDlTraffic(i)
return uu
}
// SetNillableDlTraffic sets the "dl_traffic" field if the given value is not nil.
func (uu *UserUpdate) SetNillableDlTraffic(i *int64) *UserUpdate {
if i != nil {
uu.SetDlTraffic(*i)
}
return uu
}
// AddDlTraffic adds i to the "dl_traffic" field.
func (uu *UserUpdate) AddDlTraffic(i int64) *UserUpdate {
uu.mutation.AddDlTraffic(i)
return uu
}
// SetTwoFactorSecret sets the "two_factor_secret" field.
func (uu *UserUpdate) SetTwoFactorSecret(s string) *UserUpdate {
uu.mutation.SetTwoFactorSecret(s)
@ -1017,6 +1038,12 @@ func (uu *UserUpdate) sqlSave(ctx context.Context) (n int, err error) {
if value, ok := uu.mutation.AddedCredits(); ok {
_spec.AddField(user.FieldCredits, field.TypeInt64, value)
}
if value, ok := uu.mutation.DlTraffic(); ok {
_spec.SetField(user.FieldDlTraffic, field.TypeInt64, value)
}
if value, ok := uu.mutation.AddedDlTraffic(); ok {
_spec.AddField(user.FieldDlTraffic, field.TypeInt64, value)
}
if value, ok := uu.mutation.TwoFactorSecret(); ok {
_spec.SetField(user.FieldTwoFactorSecret, field.TypeString, value)
}
@ -1905,6 +1932,27 @@ func (uuo *UserUpdateOne) AddCredits(i int64) *UserUpdateOne {
return uuo
}
// SetDlTraffic sets the "dl_traffic" field.
func (uuo *UserUpdateOne) SetDlTraffic(i int64) *UserUpdateOne {
uuo.mutation.ResetDlTraffic()
uuo.mutation.SetDlTraffic(i)
return uuo
}
// SetNillableDlTraffic sets the "dl_traffic" field if the given value is not nil.
func (uuo *UserUpdateOne) SetNillableDlTraffic(i *int64) *UserUpdateOne {
if i != nil {
uuo.SetDlTraffic(*i)
}
return uuo
}
// AddDlTraffic adds i to the "dl_traffic" field.
func (uuo *UserUpdateOne) AddDlTraffic(i int64) *UserUpdateOne {
uuo.mutation.AddDlTraffic(i)
return uuo
}
// SetTwoFactorSecret sets the "two_factor_secret" field.
func (uuo *UserUpdateOne) SetTwoFactorSecret(s string) *UserUpdateOne {
uuo.mutation.SetTwoFactorSecret(s)
@ -2698,6 +2746,12 @@ func (uuo *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error)
if value, ok := uuo.mutation.AddedCredits(); ok {
_spec.AddField(user.FieldCredits, field.TypeInt64, value)
}
if value, ok := uuo.mutation.DlTraffic(); ok {
_spec.SetField(user.FieldDlTraffic, field.TypeInt64, value)
}
if value, ok := uuo.mutation.AddedDlTraffic(); ok {
_spec.AddField(user.FieldDlTraffic, field.TypeInt64, value)
}
if value, ok := uuo.mutation.TwoFactorSecret(); ok {
_spec.SetField(user.FieldTwoFactorSecret, field.TypeString, value)
}

@ -1143,6 +1143,8 @@
"finance": "Finance",
"creditBalance": "Credit Balance",
"storageBonus": "Storage Bonus",
"dlTraffic": "Direct Link Traffic",
"dlTrafficUnlimited": "Unlimited",
"activeGrants": "Active Benefits",
"grantStorage": "Storage",
"grantGroup": "Group",
@ -1200,6 +1202,7 @@
"shop": {
"memberships": "Memberships",
"storagePacks": "Storage packs",
"trafficPacks": "Traffic packs",
"redeem": "Redeem",
"balance": "Balance: {{credits}} pts",
"purchased": "Purchased {{name}}",

@ -652,6 +652,8 @@
"pointsUnit": "points",
"groupProductSettings": "Group product",
"groupProductsDes": "Configure products that users can purchase to join specific user groups.",
"trafficProductSettings": "Traffic product",
"trafficProductsDes": "Configure products that users can purchase to top up their direct-link traffic allowance.",
"addGroupProduct": "Add group product",
"editGroupProduct": "Edit group product",
"groupId": "Group ID",
@ -913,6 +915,7 @@
"giftCodeTypePoints": "Points",
"giftCodeTypeStorage": "Storage",
"giftCodeTypeGroup": "Group",
"giftCodeTypeTraffic": "Direct Link Traffic",
"giftCodePointsAmount": "Points Amount",
"giftCodePointsAmountHelp": "Number of points to credit when code is redeemed",
"giftCodeProduct": "Product",
@ -1857,6 +1860,10 @@
"generatingResult": "Result",
"addStoragePack": "Add storage pack",
"editStoragePack": "Edit storage pack",
"addTrafficPack": "Add traffic pack",
"editTrafficPack": "Edit traffic pack",
"trafficSize": "Traffic amount (bytes)",
"trafficSizeDes": "Bytes of direct-link traffic granted on purchase; packs are permanent.",
"productNameDes": "Product display name",
"packSizeDes": "Size of storage pack",
"durationDay": "Duration (day)",

@ -1143,6 +1143,8 @@
"finance": "财务",
"creditBalance": "积分余额",
"storageBonus": "容量加成",
"dlTraffic": "直链流量",
"dlTrafficUnlimited": "不限",
"activeGrants": "生效中的权益",
"grantStorage": "容量",
"grantGroup": "用户组",
@ -1200,6 +1202,7 @@
"shop": {
"memberships": "会员",
"storagePacks": "容量包",
"trafficPacks": "流量包",
"redeem": "兑换",
"balance": "余额:{{credits}} 积分",
"purchased": "已购买 {{name}}",

@ -652,6 +652,8 @@
"pointsUnit": "积分",
"groupProductSettings": "用户组产品",
"groupProductsDes": "配置用户可以购买以加入特定用户组的产品。",
"trafficProductSettings": "流量产品",
"trafficProductsDes": "配置用户可以购买以充值直链流量额度的产品。",
"addGroupProduct": "添加用户组产品",
"editGroupProduct": "编辑用户组产品",
"groupId": "用户组 ID",
@ -913,6 +915,7 @@
"giftCodeTypePoints": "积分",
"giftCodeTypeStorage": "存储空间",
"giftCodeTypeGroup": "用户组",
"giftCodeTypeTraffic": "直链流量",
"giftCodePointsAmount": "积分数量",
"giftCodePointsAmountHelp": "兑换码被使用时将获得的积分数量。",
"giftCodeProduct": "产品",
@ -1856,6 +1859,10 @@
"generatingResult": "生成结果",
"addStoragePack": "添加容量包",
"editStoragePack": "编辑容量包",
"addTrafficPack": "添加流量包",
"editTrafficPack": "编辑流量包",
"trafficSize": "流量大小(字节)",
"trafficSizeDes": "购买后增加的直链流量字节数;流量包永久有效。",
"productNameDes": "商品展示名称。",
"packSizeDes": "容量包的大小。",
"durationDay": "有效期 (天)",

@ -640,7 +640,7 @@ export interface UpsertOAuthClientService {
export interface GiftCode {
id: number;
code: string;
type: "points" | "storage" | "group";
type: "points" | "storage" | "group" | "traffic";
amount: number;
duration?: number;
used_by_id?: number;
@ -664,7 +664,7 @@ export interface GiftCodeListResponse {
export interface Sku {
id: number;
name: string;
type: "storage" | "group";
type: "storage" | "group" | "traffic";
amount: number;
duration?: number;
price?: number;
@ -702,7 +702,7 @@ export interface ActivityEventListResponse {
}
export interface CreateGiftCodeService {
type: "points" | "storage" | "group";
type: "points" | "storage" | "group" | "traffic";
amount: number;
duration?: number;
qty: number;

@ -335,6 +335,8 @@ export interface UserGrant {
export interface CreditInfo {
credits: number;
storage_bonus: number;
// Remaining direct-link allowance in bytes; -1 means unlimited.
dl_traffic: number;
grants: UserGrant[];
}
@ -355,7 +357,7 @@ export interface CreditTxnList {
export interface ShopSku {
id: string;
name: string;
type: "storage" | "group";
type: "storage" | "group" | "traffic";
amount: number;
group?: string;
group_id?: string;

@ -68,7 +68,7 @@ const GiftCodes = () => {
const [pagination, setPagination] = useState<PaginationParams>({ page: 1, perPage: 10 });
// Generate form state
const [genType, setGenType] = useState<"points" | "storage" | "group">("points");
const [genType, setGenType] = useState<"points" | "storage" | "group" | "traffic">("points");
const [genAmount, setGenAmount] = useState(100);
const [genGroup, setGenGroup] = useState(0);
const [genDuration, setGenDuration] = useState(0);
@ -126,6 +126,7 @@ const GiftCodes = () => {
case "points":
return `${gc.amount}`;
case "storage":
case "traffic":
return sizeToString(gc.amount);
case "group":
return `#${gc.amount}`;
@ -213,6 +214,7 @@ const GiftCodes = () => {
<MenuItem value="points">{t("giftCodes.giftCodeTypePoints")}</MenuItem>
<MenuItem value="storage">{t("giftCodes.giftCodeTypeStorage")}</MenuItem>
<MenuItem value="group">{t("giftCodes.giftCodeTypeGroup")}</MenuItem>
<MenuItem value="traffic">{t("giftCodes.giftCodeTypeTraffic")}</MenuItem>
</Select>
</FormControl>
</SettingForm>
@ -226,7 +228,11 @@ const GiftCodes = () => {
onChange={(e) => setGenAmount(parseInt(e.target.value) || 0)}
/>
<NoMarginHelperText>
{genType === "points" ? t("giftCodes.giftCodePointsAmountHelp") : t("vas.packSizeDes")}
{genType === "points"
? t("giftCodes.giftCodePointsAmountHelp")
: genType === "traffic"
? t("vas.trafficSizeDes")
: t("vas.packSizeDes")}
</NoMarginHelperText>
</SettingForm>
) : (

@ -42,7 +42,7 @@ import { NoMarginHelperText } from "../Settings.tsx";
const DAY_SECONDS = 86400;
export interface SkuTableProps {
type: "storage" | "group";
type: "storage" | "group" | "traffic";
}
interface SkuForm {
@ -64,7 +64,7 @@ const emptyForm = (type: string): SkuForm => ({
id: 0,
name: "",
amount: 0,
durationDays: type === "storage" ? 365 : 30,
durationDays: type === "group" ? 30 : type === "storage" ? 365 : 0,
price: 0,
allowPoints: false,
points: 0,
@ -133,7 +133,8 @@ const SkuTable = ({ type }: SkuTableProps) => {
name: form.name,
type,
amount: form.amount,
duration: Math.max(0, form.durationDays) * DAY_SECONDS,
// Traffic packs are permanent; duration is not applicable.
duration: type === "traffic" ? 0 : Math.max(0, form.durationDays) * DAY_SECONDS,
price: Math.max(0, form.price),
points: form.allowPoints ? Math.max(1, form.points) : undefined,
label: form.label || undefined,
@ -155,13 +156,17 @@ const SkuTable = ({ type }: SkuTableProps) => {
dispatch(adminDeleteSku(id)).then(load);
};
const amountLabel = (s: Sku) => (type === "storage" ? sizeToString(s.amount) : groupName(s.amount));
const amountLabel = (s: Sku) => (type === "group" ? groupName(s.amount) : sizeToString(s.amount));
return (
<Stack spacing={2}>
<Box>
<SecondaryButton variant="contained" startIcon={<Add />} onClick={() => openEdit()}>
{type === "storage" ? t("vas.addStoragePack") : t("vas.addMembership")}
{type === "group"
? t("vas.addMembership")
: type === "traffic"
? t("vas.addTrafficPack")
: t("vas.addStoragePack")}
</SecondaryButton>
</Box>
@ -171,7 +176,7 @@ const SkuTable = ({ type }: SkuTableProps) => {
<TableHead>
<TableRow>
<NoWrapCell>{t("vas.name")}</NoWrapCell>
<NoWrapCell>{type === "storage" ? t("vas.size") : t("vas.group")}</NoWrapCell>
<NoWrapCell>{type === "group" ? t("vas.group") : t("vas.size")}</NoWrapCell>
<NoWrapCell>{t("vas.duration")}</NoWrapCell>
<NoWrapCell>{t("vas.price")}</NoWrapCell>
<NoWrapCell>{t("vas.priceCredits")}</NoWrapCell>
@ -226,7 +231,13 @@ const SkuTable = ({ type }: SkuTableProps) => {
</StyledTableContainerPaper>
<Dialog open={!!form} onClose={() => setForm(undefined)} maxWidth="sm" fullWidth>
<DialogTitle>{type === "storage" ? t("vas.editStoragePack") : t("vas.editMembership")}</DialogTitle>
<DialogTitle>
{type === "group"
? t("vas.editMembership")
: type === "traffic"
? t("vas.editTrafficPack")
: t("vas.editStoragePack")}
</DialogTitle>
<DialogContent>
{form && (
<Stack spacing={2} sx={{ mt: 1 }}>
@ -243,17 +254,7 @@ const SkuTable = ({ type }: SkuTableProps) => {
/>
</SettingForm>
{type === "storage" ? (
<SettingForm title={t("vas.size")}>
<DenseFilledTextField
fullWidth
type="number"
value={form.amount}
onChange={(e) => setForm({ ...form, amount: parseInt(e.target.value) || 0 })}
/>
<NoMarginHelperText>{t("vas.packSizeDes")}</NoMarginHelperText>
</SettingForm>
) : (
{type === "group" ? (
<SettingForm title={t("vas.purchasableGroups")}>
<FormControl fullWidth size="small">
<InputLabel>{t("vas.purchasableGroups")}</InputLabel>
@ -271,8 +272,21 @@ const SkuTable = ({ type }: SkuTableProps) => {
</FormControl>
<NoMarginHelperText>{t("vas.groupDes")}</NoMarginHelperText>
</SettingForm>
) : (
<SettingForm title={type === "traffic" ? t("vas.trafficSize") : t("vas.size")}>
<DenseFilledTextField
fullWidth
type="number"
value={form.amount}
onChange={(e) => setForm({ ...form, amount: parseInt(e.target.value) || 0 })}
/>
<NoMarginHelperText>
{type === "traffic" ? t("vas.trafficSizeDes") : t("vas.packSizeDes")}
</NoMarginHelperText>
</SettingForm>
)}
{type !== "traffic" && (
<SettingForm title={t("vas.durationDay")}>
<DenseFilledTextField
fullWidth
@ -284,6 +298,7 @@ const SkuTable = ({ type }: SkuTableProps) => {
{type === "storage" ? t("vas.durationDayDes") : t("vas.durationGroupDes")}
</NoMarginHelperText>
</SettingForm>
)}
<SettingForm title={t("vas.priceYuan")}>
<DenseFilledTextField

@ -198,6 +198,20 @@ const VAS = () => {
</SettingSectionContent>
</SettingSection>
<SettingSection>
<Typography variant="h6" gutterBottom sx={{ display: "flex", alignItems: "center" }}>
{t("settings.trafficProductSettings")}
</Typography>
<SettingSectionContent>
<SettingForm lgWidth={12}>
<FormControl fullWidth>
<SkuTable type="traffic" />
<NoMarginHelperText>{t("settings.trafficProductsDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</SettingSectionContent>
</SettingSection>
<SettingSection>
<Typography variant="h6" gutterBottom sx={{ display: "flex", alignItems: "center" }}>
{t("giftCodes.giftCodesSettings")}

@ -75,6 +75,14 @@ const FinanceSetting = () => {
<Typography variant="h4">{sizeToString(info.storage_bonus)}</Typography>
</Paper>
)}
<Paper variant="outlined" sx={{ p: 2, minWidth: 200 }}>
<Typography variant="subtitle2" color="text.secondary">
{t("setting.dlTraffic")}
</Typography>
<Typography variant="h4">
{info.dl_traffic < 0 ? t("setting.dlTrafficUnlimited") : sizeToString(info.dl_traffic)}
</Typography>
</Paper>
</Stack>
{info.grants.length > 0 && (

@ -19,6 +19,7 @@ import PageHeader, { PageTabQuery } from "../PageHeader.tsx";
export enum ShopPageTab {
Membership = "membership",
Storage = "storage",
Traffic = "traffic",
Redeem = "redeem",
}
@ -45,6 +46,7 @@ const Shop = () => {
() => [
{ label: t("application:shop.memberships"), value: ShopPageTab.Membership },
{ label: t("application:shop.storagePacks"), value: ShopPageTab.Storage },
{ label: t("application:shop.trafficPacks"), value: ShopPageTab.Traffic },
{ label: t("application:shop.redeem"), value: ShopPageTab.Redeem },
],
[],
@ -53,7 +55,11 @@ const Shop = () => {
const filtered = useMemo(
() =>
(skus ?? []).filter((s) =>
tab === ShopPageTab.Membership ? s.type === "group" : s.type === "storage",
tab === ShopPageTab.Membership
? s.type === "group"
: tab === ShopPageTab.Traffic
? s.type === "traffic"
: s.type === "storage",
),
[skus, tab],
);
@ -70,7 +76,7 @@ const Shop = () => {
const skuSubtitle = (s: ShopSku) => {
const parts = [
s.type === "storage" ? sizeToString(s.amount) : s.group,
s.type === "group" ? s.group : sizeToString(s.amount),
s.duration > 0 ? formatDuration(dayjs.duration(s.duration, "seconds")) : t("shop.permanent"),
];
return parts.filter(Boolean).join(" · ");

@ -0,0 +1,96 @@
package inventory
import (
"context"
"testing"
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/stretchr/testify/require"
)
func TestConsumeDirectTraffic(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
g := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
uc := NewUserClient(client)
t.Run("unlimited user always succeeds", func(t *testing.T) {
// Default dl_traffic is -1 (unlimited).
u := client.User.Create().SetEmail("un@example.com").SetNick("un").SetGroup(g).SaveX(ctx)
require.Equal(t, int64(-1), u.DlTraffic)
ok, err := uc.ConsumeDirectTraffic(ctx, u.ID, 1<<40)
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, int64(-1), client.User.GetX(ctx, u.ID).DlTraffic)
})
t.Run("finite balance decremented", func(t *testing.T) {
u := client.User.Create().SetEmail("fin@example.com").SetNick("fin").SetGroup(g).SetDlTraffic(1000).SaveX(ctx)
ok, err := uc.ConsumeDirectTraffic(ctx, u.ID, 400)
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, int64(600), client.User.GetX(ctx, u.ID).DlTraffic)
// Exact remainder is consumable.
ok, err = uc.ConsumeDirectTraffic(ctx, u.ID, 600)
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, int64(0), client.User.GetX(ctx, u.ID).DlTraffic)
})
t.Run("insufficient balance rejected without going negative", func(t *testing.T) {
u := client.User.Create().SetEmail("ins@example.com").SetNick("ins").SetGroup(g).SetDlTraffic(100).SaveX(ctx)
ok, err := uc.ConsumeDirectTraffic(ctx, u.ID, 101)
require.NoError(t, err)
require.False(t, ok)
require.Equal(t, int64(100), client.User.GetX(ctx, u.ID).DlTraffic)
// Zero balance rejects any positive size.
empty := client.User.Create().SetEmail("empty@example.com").SetNick("empty").SetGroup(g).SetDlTraffic(0).SaveX(ctx)
ok, err = uc.ConsumeDirectTraffic(ctx, empty.ID, 1)
require.NoError(t, err)
require.False(t, ok)
require.Equal(t, int64(0), client.User.GetX(ctx, empty.ID).DlTraffic)
})
t.Run("non-positive size is a no-op", func(t *testing.T) {
u := client.User.Create().SetEmail("z@example.com").SetNick("z").SetGroup(g).SetDlTraffic(10).SaveX(ctx)
ok, err := uc.ConsumeDirectTraffic(ctx, u.ID, 0)
require.NoError(t, err)
require.True(t, ok)
ok, err = uc.ConsumeDirectTraffic(ctx, u.ID, -5)
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, int64(10), client.User.GetX(ctx, u.ID).DlTraffic)
})
}
func TestAddDirectTraffic(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
g := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
uc := NewUserClient(client)
// Finite balance grows.
limited := client.User.Create().SetEmail("lim@example.com").SetNick("lim").SetGroup(g).SetDlTraffic(100).SaveX(ctx)
require.NoError(t, uc.AddDirectTraffic(ctx, limited.ID, 50))
require.Equal(t, int64(150), client.User.GetX(ctx, limited.ID).DlTraffic)
// Unlimited stays unlimited.
unlimited := client.User.Create().SetEmail("unl@example.com").SetNick("unl").SetGroup(g).SaveX(ctx)
require.NoError(t, uc.AddDirectTraffic(ctx, unlimited.ID, 1<<40))
require.Equal(t, int64(-1), client.User.GetX(ctx, unlimited.ID).DlTraffic)
// Non-positive size is a no-op.
require.NoError(t, uc.AddDirectTraffic(ctx, limited.ID, 0))
require.NoError(t, uc.AddDirectTraffic(ctx, limited.ID, -10))
require.Equal(t, int64(150), client.User.GetX(ctx, limited.ID).DlTraffic)
}

@ -91,6 +91,13 @@ type (
// ReleaseStorage unconditionally subtracts size bytes from user uid's
// storage. Used to undo a previous ReserveStorage on failure paths.
ReleaseStorage(ctx context.Context, uid int, size int64) error
// ConsumeDirectTraffic atomically subtracts size bytes from user
// uid's dl_traffic allowance. Unlimited users (-1) always pass; a
// limited balance that cannot cover size reports false.
ConsumeDirectTraffic(ctx context.Context, uid int, size int64) (bool, error)
// AddDirectTraffic credits size bytes to user uid's dl_traffic
// allowance. Unlimited users (-1) are unaffected.
AddDirectTraffic(ctx context.Context, uid int, size int64) error
// UpdateAvatar updates user avatar.
UpdateAvatar(ctx context.Context, u *ent.User, avatar string) (*ent.User, error)
// UpdateNickname updates user nickname.
@ -413,6 +420,39 @@ func (c *userClient) ReleaseStorage(ctx context.Context, uid int, size int64) er
return c.client.User.Update().Where(user.ID(uid)).AddStorage(-size).Exec(ctx)
}
// ConsumeDirectTraffic implements UserClient.ConsumeDirectTraffic.
func (c *userClient) ConsumeDirectTraffic(ctx context.Context, uid int, size int64) (bool, error) {
if size <= 0 {
return true, nil
}
u, err := c.client.User.Query().Where(user.ID(uid)).Select(user.FieldDlTraffic).First(ctx)
if err != nil {
return false, err
}
if u.DlTraffic < 0 {
return true, nil
}
n, err := c.client.User.Update().
Where(user.ID(uid), user.DlTrafficGTE(size)).
AddDlTraffic(-size).
Save(ctx)
if err != nil {
return false, err
}
return n > 0, nil
}
// AddDirectTraffic implements UserClient.AddDirectTraffic.
func (c *userClient) AddDirectTraffic(ctx context.Context, uid int, size int64) error {
if size <= 0 {
return nil
}
return c.client.User.Update().
Where(user.ID(uid), user.DlTrafficGTE(0)).
AddDlTraffic(size).
Exec(ctx)
}
func (c *userClient) CalculateStorage(ctx context.Context, uid int) (int64, error) {
var sum int64
batchSize := 30000

@ -235,6 +235,8 @@ func (c *vasClient) RedeemGiftCode(ctx context.Context, userID int, code string)
err = txVc.createGrant(ctx, userID, usergrant.TypeStorage, gc.Amount, gc.Duration, 0)
case giftcode.TypeGroup:
err = txVc.applyGroupCode(ctx, userID, gc)
case giftcode.TypeTraffic:
err = txVc.client.User.Update().Where(user.ID(userID), user.DlTrafficGTE(0)).AddDlTraffic(gc.Amount).Exec(ctx)
}
if err != nil {
_ = Rollback(tx)
@ -431,6 +433,10 @@ func (c *vasClient) PurchaseSku(ctx context.Context, userID int, s *ent.Sku) err
err = txVc.createGrant(ctx, userID, usergrant.TypeStorage, s.Amount, s.Duration, 0)
case sku.TypeGroup:
err = txVc.applyGroupGrant(ctx, userID, int(s.Amount), s.Duration)
case sku.TypeTraffic:
// Traffic packs are permanent additions to dl_traffic; duration
// does not apply. Unlimited balances stay unlimited.
err = txVc.client.User.Update().Where(user.ID(userID), user.DlTrafficGTE(0)).AddDlTraffic(s.Amount).Exec(ctx)
}
if err != nil {
return Rollback(tx)

@ -245,6 +245,56 @@ func TestPurchaseSku(t *testing.T) {
require.Equal(t, int64(1024), bonus)
}
func TestPurchaseTrafficSku(t *testing.T) {
ctx := context.Background()
client, c := newVasClient(t)
group, u := vasFixture(t, client)
require.NoError(t, c.CreditAdjust(ctx, u.ID, 500, credittxn.TypeAdjust, "", "seed"))
// Start the user with a finite allowance — unlimited users stay unlimited.
require.NoError(t, client.User.UpdateOne(u).SetDlTraffic(100).Exec(ctx))
points := int64(200)
trafficSku, err := c.UpsertSku(ctx, &ent.Sku{
Name: "1GB traffic", Type: sku.TypeTraffic, Amount: 1024,
Points: &points, Enabled: true,
})
require.NoError(t, err)
require.NoError(t, c.PurchaseSku(ctx, u.ID, trafficSku))
u = client.User.GetX(ctx, u.ID)
require.Equal(t, int64(300), u.Credits)
require.Equal(t, int64(1124), u.DlTraffic)
// Unlimited users remain unlimited after purchase.
u2 := client.User.Create().SetEmail("u2@example.com").SetNick("u2").SetGroup(group).SaveX(ctx)
require.NoError(t, c.CreditAdjust(ctx, u2.ID, 500, credittxn.TypeAdjust, "", "seed"))
require.NoError(t, c.PurchaseSku(ctx, u2.ID, trafficSku))
require.Equal(t, int64(-1), client.User.GetX(ctx, u2.ID).DlTraffic)
}
func TestRedeemTrafficGiftCode(t *testing.T) {
ctx := context.Background()
client, c := newVasClient(t)
group, u := vasFixture(t, client)
require.NoError(t, client.User.UpdateOne(u).SetDlTraffic(10).Exec(ctx))
codes, err := c.CreateGiftCodes(ctx, &CreateGiftCodeParams{
Type: giftcode.TypeTraffic, Amount: 2048, Qty: 2,
})
require.NoError(t, err)
_, err = c.RedeemGiftCode(ctx, u.ID, codes[0].Code)
require.NoError(t, err)
require.Equal(t, int64(2058), client.User.GetX(ctx, u.ID).DlTraffic)
// Unlimited redeemer keeps unlimited balance.
u2 := client.User.Create().SetEmail("u2@example.com").SetNick("u2").SetGroup(group).SaveX(ctx)
_, err = c.RedeemGiftCode(ctx, u2.ID, codes[1].Code)
require.NoError(t, err)
require.Equal(t, int64(-1), client.User.GetX(ctx, u2.ID).DlTraffic)
}
func paidShareFixture(t *testing.T, client *ent.Client, price int) (*ent.User, *ent.User, *ent.Share) {
return paidShareFixtureN(t, client, price, 0)
}

@ -266,6 +266,8 @@ const (
// CodeVaultLocked 私密空间未解锁
CodeVaultLocked = 40093
// CodeInsufficientTraffic 直链流量不足
CodeInsufficientTraffic = 40094
// CodeSmsCodeErr 短信验证码错误或已过期
CodeSmsCodeErr = 40095
// CodeFailedSendSms 短信发送失败

@ -42,7 +42,7 @@ type (
// CreateGiftCodeService generates qty codes sharing one grant definition.
CreateGiftCodeService struct {
Type giftcode.Type `json:"type" binding:"required,oneof=points storage group"`
Type giftcode.Type `json:"type" binding:"required,oneof=points storage group traffic"`
Amount int64 `json:"amount" binding:"required,min=1"`
Duration int64 `json:"duration" binding:"omitempty,min=0"`
Qty int `json:"qty" binding:"required,min=1,max=500"`

@ -808,6 +808,20 @@ func RedirectDirectLink(c *gin.Context, name string, download bool) error {
return serializer.NewError(serializer.CodeNotFound, "direct link not found", err)
}
// Direct-link traffic allowance: the owner's dl_traffic balance is
// charged the file's size once, at redirect time, since the actual
// bytes are served by a signed entity URL after this point.
owner := dl.Edges.File.Edges.Owner
if owner != nil {
ok, err := dep.UserClient().ConsumeDirectTraffic(c, owner.ID, dl.Edges.File.Size)
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to check direct link traffic", err)
}
if !ok {
return serializer.NewError(serializer.CodeInsufficientTraffic, "Direct link traffic exhausted", nil)
}
}
m := manager.NewFileManager(dep, dl.Edges.File.Edges.Owner)
defer m.Recycle()

@ -63,7 +63,10 @@ type (
CreditResponse struct {
Credits int64 `json:"credits"`
StorageBonus int64 `json:"storage_bonus"`
Grants []*ent.UserGrant `json:"grants"`
// DlTraffic is the remaining direct-link allowance in bytes;
// -1 means unlimited.
DlTraffic int64 `json:"dl_traffic"`
Grants []*ent.UserGrant `json:"grants"`
}
CreditTxnListResponse struct {
@ -76,6 +79,12 @@ func (service *CreditService) Get(c *gin.Context) (*CreditResponse, error) {
dep := dependency.FromContext(c)
u := inventory.UserFromContext(c)
// Re-read the user: balances may have just changed (e.g. after a
// purchase) while the context still carries the pre-request snapshot.
if fresh, err := dep.UserClient().GetByID(c, u.ID); err == nil {
u = fresh
}
bonus, err := dep.VasClient().StorageBonus(c, u.ID)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get storage bonus", err)
@ -89,6 +98,7 @@ func (service *CreditService) Get(c *gin.Context) (*CreditResponse, error) {
return &CreditResponse{
Credits: u.Credits,
StorageBonus: bonus,
DlTraffic: u.DlTraffic,
Grants: grants,
}, nil
}

Loading…
Cancel
Save