chore: import upstream snapshot with attribution
CI / Check and lint (push) Failing after 1s
Lint / Lint (ubuntu-latest) (push) Failing after 1s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:32 +08:00
commit 39dbe3a57d
1131 changed files with 272709 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin
import (
"github.com/gin-gonic/gin"
)
type Agent interface {
Base
RegisterUnAuthRouter(r *gin.RouterGroup)
RegisterAuthUserRouter(r *gin.RouterGroup)
RegisterAuthAdminRouter(r *gin.RouterGroup)
}
var (
CallAgent,
registerAgent = MakePlugin[Agent](true)
siteURLFn func() string
)
// SiteURL The site url is the domain address of the current site. e.g. http://localhost:8080
// When some Agent plugins want to redirect to the origin site, it can use this function to get the site url.
func SiteURL() string {
if siteURLFn != nil {
return siteURLFn()
}
return ""
}
// RegisterGetSiteURLFunc Register a function to get the site url.
func RegisterGetSiteURLFunc(fn func() string) {
siteURLFn = fn
}
+42
View File
@@ -0,0 +1,42 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin
// Info presents the plugin information
type Info struct {
Name Translator
SlugName string
Description Translator
Author string
Version string
Link string
}
// Base is the base plugin
type Base interface {
// Info returns the plugin information
Info() Info
}
var (
// CallBase is a function that calls all registered base plugins
CallBase,
registerBase = MakePlugin[Base](true)
)
+44
View File
@@ -0,0 +1,44 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin
import (
"context"
"time"
)
type Cache interface {
Base
GetString(ctx context.Context, key string) (data string, exist bool, err error)
SetString(ctx context.Context, key, value string, ttl time.Duration) (err error)
GetInt64(ctx context.Context, key string) (data int64, exist bool, err error)
SetInt64(ctx context.Context, key string, value int64, ttl time.Duration) (err error)
Increase(ctx context.Context, key string, value int64) (data int64, err error)
Decrease(ctx context.Context, key string, value int64) (data int64, err error)
Del(ctx context.Context, key string) (err error)
Flush(ctx context.Context) (err error)
}
var (
// CallCache is a function that calls all registered cache
CallCache,
registerCache = MakePlugin[Cache](false)
)
+82
View File
@@ -0,0 +1,82 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin
type Captcha interface {
Base
// GetConfig required. Get the captcha plugin configuration.
// The configuration is used to generate the captcha for frontend. Such as the token for third-party service.
GetConfig() (configJsonStr string)
// Create optional. If this plugin need to create captcha via backend, implement this method.
// On other hand, if this plugin create captcha via third-party service, ignore this method.
// Return captcha: The captcha image base64 string, code: The real captcha code.
Create() (captcha, code string)
// Verify required. Verify the user input captcha is correct or not
// captcha: The captchaCode generated by Create method, if not implemented, it's empty.
Verify(captchaCode, userInput string) (pass bool)
}
var (
// CallCaptcha is a function that calls all registered parsers
callCaptcha,
registerCaptcha = MakePlugin[Captcha](false)
)
func CallCaptcha(fn func(fn Captcha) error) error {
slugName := ""
_ = callCaptcha(func(captcha Captcha) error {
slugName = captcha.Info().SlugName
return nil
})
if slugName == "" {
return nil
}
return callCaptcha(func(captcha Captcha) error {
if captcha.Info().SlugName == slugName {
return fn(captcha)
}
return nil
})
}
func CaptchaEnabled() (enabled bool) {
_ = callCaptcha(func(fn Captcha) error {
enabled = true
return nil
})
return
}
func coordinatedCaptchaPlugins(slugName string) (enabledSlugNames []string) {
isCaptcha := false
_ = callCaptcha(func(captcha Captcha) error {
name := captcha.Info().SlugName
if slugName == name {
isCaptcha = true
} else {
enabledSlugNames = append(enabledSlugNames, name)
}
return nil
})
if isCaptcha {
return enabledSlugNames
}
return nil
}
+65
View File
@@ -0,0 +1,65 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin
var (
DefaultCDNFileType = map[string]bool{
".ico": true,
".json": true,
".css": true,
".js": true,
".webp": true,
".woff2": true,
".woff": true,
".jpg": true,
".svg": true,
".png": true,
".map": true,
".txt": true,
}
)
type CDN interface {
Base
GetStaticPrefix() string
}
var (
// CallCDN is a function that calls all registered parsers
CallCDN,
registerCDN = MakePlugin[CDN](false)
)
func coordinatedCDNPlugins(slugName string) (enabledSlugNames []string) {
isCDN := false
_ = CallCDN(func(cdn CDN) error {
name := cdn.Info().SlugName
if slugName == name {
isCDN = true
} else {
enabledSlugNames = append(enabledSlugNames, name)
}
return nil
})
if isCDN {
return enabledSlugNames
}
return nil
}
+135
View File
@@ -0,0 +1,135 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin
type ConfigType string
type InputType string
const (
ConfigTypeInput ConfigType = "input"
ConfigTypeTextarea ConfigType = "textarea"
ConfigTypeCheckbox ConfigType = "checkbox"
ConfigTypeRadio ConfigType = "radio"
ConfigTypeSelect ConfigType = "select"
ConfigTypeUpload ConfigType = "upload"
ConfigTypeTimezone ConfigType = "timezone"
ConfigTypeSwitch ConfigType = "switch"
ConfigTypeButton ConfigType = "button"
ConfigTypeLegend ConfigType = "legend"
ConfigTypeTagSelector ConfigType = "tag_selector"
)
const (
InputTypeText InputType = "text"
InputTypeColor InputType = "color"
InputTypeDate InputType = "date"
InputTypeDatetime InputType = "datetime-local"
InputTypeEmail InputType = "email"
InputTypeMonth InputType = "month"
InputTypeNumber InputType = "number"
InputTypePassword InputType = "password"
InputTypeRange InputType = "range"
InputTypeSearch InputType = "search"
InputTypeTel InputType = "tel"
InputTypeTime InputType = "time"
InputTypeUrl InputType = "url"
InputTypeWeek InputType = "week"
)
type ConfigField struct {
Name string `json:"name"`
Type ConfigType `json:"type"`
Title Translator `json:"title"`
Description Translator `json:"description"`
Required bool `json:"required"`
Value any `json:"value"`
UIOptions ConfigFieldUIOptions `json:"ui_options"`
Options []ConfigFieldOption `json:"options,omitempty"`
}
type ConfigFieldUIOptions struct {
Placeholder Translator `json:"placeholder"`
Rows string `json:"rows,omitempty"`
InputType InputType `json:"input_type,omitempty"`
Label Translator `json:"label"`
Action *UIOptionAction `json:"action,omitempty"`
Variant string `json:"variant,omitempty"`
Text Translator `json:"text"`
ClassName string `json:"class_name,omitempty"`
FieldClassName string `json:"field_class_name,omitempty"`
}
type ConfigFieldOption struct {
Label Translator `json:"label"`
Value string `json:"value"`
}
type UIOptionAction struct {
Url string `json:"url"`
Method string `json:"method,omitempty"`
Loading *LoadingAction `json:"loading,omitempty"`
OnComplete *OnCompleteAction `json:"on_complete,omitempty"`
}
const (
LoadingActionStateNone LoadingActionType = "none"
LoadingActionStatePending LoadingActionType = "pending"
LoadingActionStateComplete LoadingActionType = "completed"
)
type LoadingActionType string
type LoadingAction struct {
Text Translator `json:"text"`
State LoadingActionType `json:"state"`
}
type OnCompleteAction struct {
ToastReturnMessage bool `json:"toast_return_message"`
RefreshFormConfig bool `json:"refresh_form_config"`
}
// TagSelectorOption represents a tag option in the tag selector config value field
type TagSelectorOption struct {
TagID string `json:"tag_id"`
SlugName string `json:"slug_name"`
DisplayName string `json:"display_name"`
Recommend bool `json:"recommend"`
Reserved bool `json:"reserved"`
}
type Config interface {
Base
// ConfigFields returns the list of config fields
ConfigFields() []ConfigField
// ConfigReceiver receives the config data, it calls when the config is saved or initialized.
// We recommend to unmarshal the data to a struct, and then use the struct to do something.
// The config is encoded in JSON format.
// It depends on the definition of ConfigFields.
ConfigReceiver(config []byte) error
}
var (
// CallConfig is a function that calls all registered config plugins
CallConfig,
registerConfig = MakePlugin[Config](true)
)
+68
View File
@@ -0,0 +1,68 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin
type Connector interface {
Base
// ConnectorLogoSVG presents the logo in svg format
ConnectorLogoSVG() string
// ConnectorName presents the name of the connector
// e.g. Facebook, Twitter, Instagram
ConnectorName() Translator
// ConnectorSlugName presents the slug name of the connector
// Please use lowercase and hyphen as the separator
// e.g. facebook, twitter, instagram
ConnectorSlugName() string
// ConnectorSender presents the sender of the connector
// It handles the start endpoint of the connector
// receiverURL is the whole URL of the receiver
ConnectorSender(ctx *GinContext, receiverURL string) (redirectURL string)
// ConnectorReceiver presents the receiver of the connector
// It handles the callback endpoint of the connector, and returns the
ConnectorReceiver(ctx *GinContext, receiverURL string) (userInfo ExternalLoginUserInfo, err error)
}
// ExternalLoginUserInfo external login user info
type ExternalLoginUserInfo struct {
// required. The unique user ID provided by the third-party login
ExternalID string
// optional. This name is used preferentially during registration
DisplayName string
// optional. This username is used preferentially during registration
Username string
// optional. If email exist will bind the existing user
// IMPORTANT: The email must have been verified. If the plugin can't guarantee the email is verified, please leave it empty.
Email string
// optional. The avatar URL provided by the third-party login platform
Avatar string
// optional. The original user information provided by the third-party login platform
MetaInfo string
}
var (
// CallConnector is a function that calls all registered connectors
CallConnector,
registerConnector = MakePlugin[Connector](false)
)
+38
View File
@@ -0,0 +1,38 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin
import "github.com/gin-gonic/gin"
type EmbedConfig struct {
Platform string `json:"platform"`
Enable bool `json:"enable"`
}
type Embed interface {
Base
GetEmbedConfigs(ctx *gin.Context) (embedConfigs []*EmbedConfig, err error)
}
var (
// CallEmbed is a function that calls all registered parsers
CallEmbed,
registerEmbed = MakePlugin[Embed](false)
)
+31
View File
@@ -0,0 +1,31 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin
type Filter interface {
Base
FilterText(text string) (err error)
}
var (
// CallFilter is a function that calls all registered parsers
CallFilter,
registerFilter = MakePlugin[Filter](false)
)
+62
View File
@@ -0,0 +1,62 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin
import (
"context"
)
type QuestionImporterInfo struct {
Title string `json:"title"`
Content string `json:"content"`
Tags []string `json:"tags"`
UserEmail string `json:"user_email"`
}
type Importer interface {
Base
RegisterImporterFunc(ctx context.Context, importer ImporterFunc)
}
type ImporterFunc interface {
AddQuestion(ctx context.Context, questionInfo QuestionImporterInfo) (err error)
}
var (
// CallImporter is a function that calls all registered parsers
CallImporter,
registerImporter = MakePlugin[Importer](false)
)
func ImporterEnabled() (enabled bool) {
_ = CallImporter(func(fn Importer) error {
enabled = true
return nil
})
return
}
func GetImporter() (ip Importer, ok bool) {
_ = CallImporter(func(fn Importer) error {
ip = fn
ok = true
return nil
})
return
}
+336
View File
@@ -0,0 +1,336 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin
import (
"context"
"fmt"
"math/rand/v2"
"time"
"github.com/apache/answer/internal/entity"
"github.com/segmentfault/pacman/cache"
"github.com/segmentfault/pacman/log"
"xorm.io/builder"
"xorm.io/xorm"
)
// Error variables for KV storage operations
var (
// ErrKVKeyNotFound is returned when the requested key does not exist in the KV storage
ErrKVKeyNotFound = fmt.Errorf("key not found in KV storage")
// ErrKVGroupEmpty is returned when a required group name is empty
ErrKVGroupEmpty = fmt.Errorf("group name is empty")
// ErrKVKeyEmpty is returned when a required key name is empty
ErrKVKeyEmpty = fmt.Errorf("key name is empty")
// ErrKVKeyAndGroupEmpty is returned when both key and group names are empty
ErrKVKeyAndGroupEmpty = fmt.Errorf("both key and group are empty")
// ErrKVTransactionFailed is returned when a KV storage transaction operation fails
ErrKVTransactionFailed = fmt.Errorf("KV storage transaction failed")
)
// KVParams is the parameters for KV storage operations
type KVParams struct {
Group string
Key string
Value string
Page int
PageSize int
}
// KVOperator provides methods to interact with the key-value storage system for plugins
type KVOperator struct {
data *Data
session *xorm.Session
pluginSlugName string
cacheTTL time.Duration
}
// KVStorageOption defines a function type that configures a KVOperator
type KVStorageOption func(*KVOperator)
// WithCacheTTL is the option to set the cache TTL; the default value is 30 minutes.
// If ttl is less than 0, the cache will not be used
func WithCacheTTL(ttl time.Duration) KVStorageOption {
return func(kv *KVOperator) {
kv.cacheTTL = ttl
}
}
// Option is used to set the options for the KV storage
func (kv *KVOperator) Option(opts ...KVStorageOption) {
for _, opt := range opts {
opt(kv)
}
}
func (kv *KVOperator) getSession(ctx context.Context) (*xorm.Session, func()) {
session := kv.session
cleanup := func() {}
if session == nil {
session = kv.data.DB.NewSession().Context(ctx)
cleanup = func() {
if session != nil {
_ = session.Close()
}
}
}
return session, cleanup
}
func (kv *KVOperator) getCacheKey(params KVParams) string {
return fmt.Sprintf("plugin_kv_storage:%s:group:%s:key:%s", kv.pluginSlugName, params.Group, params.Key)
}
func (kv *KVOperator) setCache(ctx context.Context, params KVParams) {
if kv.cacheTTL < 0 {
return
}
ttl := kv.cacheTTL
if ttl > 10 {
ttl += time.Duration(float64(ttl) * 0.1 * (1 - rand.Float64()))
}
cacheKey := kv.getCacheKey(params)
if err := kv.data.Cache.SetString(ctx, cacheKey, params.Value, ttl); err != nil {
log.Warnf("cache set failed: %v, key: %s", err, cacheKey)
}
}
func (kv *KVOperator) getCache(ctx context.Context, params KVParams) (string, bool, error) {
if kv.cacheTTL < 0 {
return "", false, nil
}
cacheKey := kv.getCacheKey(params)
return kv.data.Cache.GetString(ctx, cacheKey)
}
func (kv *KVOperator) cleanCache(ctx context.Context, params KVParams) {
if kv.cacheTTL < 0 {
return
}
if err := kv.data.Cache.Del(ctx, kv.getCacheKey(params)); err != nil {
log.Warnf("Failed to delete cache for key %s: %v", params.Key, err)
}
}
// Get retrieves a value from KV storage by group and key.
// Returns the value as a string or an error if the key is not found.
func (kv *KVOperator) Get(ctx context.Context, params KVParams) (string, error) {
if params.Key == "" {
return "", ErrKVKeyEmpty
}
if value, exist, err := kv.getCache(ctx, params); err == nil && exist {
return value, nil
}
// query
data := entity.PluginKVStorage{}
query, cleanup := kv.getSession(ctx)
defer cleanup()
query.Where(builder.Eq{
"plugin_slug_name": kv.pluginSlugName,
"`group`": params.Group,
"`key`": params.Key,
})
has, err := query.Get(&data)
if err != nil {
return "", err
}
if !has {
return "", ErrKVKeyNotFound
}
params.Value = data.Value
kv.setCache(ctx, params)
return data.Value, nil
}
// Set stores a value in KV storage with the specified group and key.
// Updates the value if it already exists.
func (kv *KVOperator) Set(ctx context.Context, params KVParams) error {
if params.Key == "" {
return ErrKVKeyEmpty
}
query, cleanup := kv.getSession(ctx)
defer cleanup()
data := &entity.PluginKVStorage{
PluginSlugName: kv.pluginSlugName,
Group: params.Group,
Key: params.Key,
Value: params.Value,
}
kv.cleanCache(ctx, params)
affected, err := query.Where(builder.Eq{
"plugin_slug_name": kv.pluginSlugName,
"`group`": params.Group,
"`key`": params.Key,
}).Cols("value").Update(data)
if err != nil {
return err
}
if affected == 0 {
_, err = query.Insert(data)
if err != nil {
return err
}
}
return nil
}
// Del removes values from KV storage by group and/or key.
// If both group and key are provided, only that specific entry is deleted.
// If only group is provided, all entries in that group are deleted.
// At least one of group or key must be provided.
func (kv *KVOperator) Del(ctx context.Context, params KVParams) error {
if params.Key == "" && params.Group == "" {
return ErrKVKeyAndGroupEmpty
}
kv.cleanCache(ctx, params)
session, cleanup := kv.getSession(ctx)
defer cleanup()
session.Where(builder.Eq{
"plugin_slug_name": kv.pluginSlugName,
})
if params.Group != "" {
session.Where(builder.Eq{"`group`": params.Group})
}
if params.Key != "" {
session.Where(builder.Eq{"`key`": params.Key})
}
_, err := session.Delete(&entity.PluginKVStorage{})
return err
}
// GetByGroup retrieves all key-value pairs for a specific group with pagination support.
// Returns a map of keys to values or an error if the group is empty or not found.
func (kv *KVOperator) GetByGroup(ctx context.Context, params KVParams) (map[string]string, error) {
if params.Group == "" {
return nil, ErrKVGroupEmpty
}
if params.Page < 1 {
params.Page = 1
}
if params.PageSize < 1 {
params.PageSize = 10
}
query, cleanup := kv.getSession(ctx)
defer cleanup()
var items []entity.PluginKVStorage
err := query.Where(builder.Eq{"plugin_slug_name": kv.pluginSlugName, "`group`": params.Group}).
Limit(params.PageSize, (params.Page-1)*params.PageSize).
OrderBy("id ASC").
Find(&items)
if err != nil {
return nil, err
}
result := make(map[string]string, len(items))
for _, item := range items {
result[item.Key] = item.Value
}
return result, nil
}
// Tx executes a function within a transaction context. If the KVOperator already has a session,
// it will use that session. Otherwise, it creates a new transaction session.
// The transaction will be committed if the function returns nil, or rolled back if it returns an error.
func (kv *KVOperator) Tx(ctx context.Context, fn func(ctx context.Context, kv *KVOperator) error) error {
var (
txKv = kv
shouldCommit bool
)
if kv.session == nil {
session := kv.data.DB.NewSession().Context(ctx)
if err := session.Begin(); err != nil {
_ = session.Close()
return fmt.Errorf("%w: begin transaction failed: %v", ErrKVTransactionFailed, err)
}
defer func() {
if !shouldCommit {
if rollbackErr := session.Rollback(); rollbackErr != nil {
log.Errorf("rollback failed: %v", rollbackErr)
}
}
_ = session.Close()
}()
txKv = &KVOperator{
session: session,
data: kv.data,
pluginSlugName: kv.pluginSlugName,
}
shouldCommit = true
}
if err := fn(ctx, txKv); err != nil {
return fmt.Errorf("%w: %v", ErrKVTransactionFailed, err)
}
if shouldCommit {
if err := txKv.session.Commit(); err != nil {
return fmt.Errorf("%w: commit failed: %v", ErrKVTransactionFailed, err)
}
}
return nil
}
// KVStorage defines the interface for plugins that need data storage capabilities
type KVStorage interface {
Info() Info
SetOperator(operator *KVOperator)
}
var (
CallKVStorage,
registerKVStorage = MakePlugin[KVStorage](true)
)
// NewKVOperator creates a new KV storage operator with the specified database engine, cache and plugin name.
// It returns a KVOperator instance that can be used to interact with the plugin's storage.
func NewKVOperator(db *xorm.Engine, cache cache.Cache, pluginSlugName string) *KVOperator {
return &KVOperator{
data: &Data{DB: db, Cache: cache},
pluginSlugName: pluginSlugName,
cacheTTL: 30 * time.Minute,
}
}
+92
View File
@@ -0,0 +1,92 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin
// NotificationType is the type of the notification
type NotificationType string
const (
NotificationUpdateQuestion NotificationType = "notification.action.update_question"
NotificationAnswerTheQuestion NotificationType = "notification.action.answer_the_question"
NotificationUpVotedTheQuestion NotificationType = "notification.action.up_voted_question"
NotificationDownVotedTheQuestion NotificationType = "notification.action.down_voted_question"
NotificationUpdateAnswer NotificationType = "notification.action.update_answer"
NotificationAcceptAnswer NotificationType = "notification.action.accept_answer"
NotificationUpVotedTheAnswer NotificationType = "notification.action.up_voted_answer"
NotificationDownVotedTheAnswer NotificationType = "notification.action.down_voted_answer"
NotificationCommentQuestion NotificationType = "notification.action.comment_question"
NotificationCommentAnswer NotificationType = "notification.action.comment_answer"
NotificationUpVotedTheComment NotificationType = "notification.action.up_voted_comment"
NotificationReplyToYou NotificationType = "notification.action.reply_to_you"
NotificationMentionYou NotificationType = "notification.action.mention_you"
NotificationYourQuestionIsClosed NotificationType = "notification.action.your_question_is_closed"
NotificationYourQuestionWasDeleted NotificationType = "notification.action.your_question_was_deleted"
NotificationYourAnswerWasDeleted NotificationType = "notification.action.your_answer_was_deleted"
NotificationYourCommentWasDeleted NotificationType = "notification.action.your_comment_was_deleted"
NotificationInvitedYouToAnswer NotificationType = "notification.action.invited_you_to_answer"
NotificationNewQuestion NotificationType = "notification.action.new_question"
NotificationNewQuestionFollowedTag NotificationType = "notification.action.new_question_followed_tag"
)
type Notification interface {
Base
// GetNewQuestionSubscribers returns the subscribers of the new question notification
GetNewQuestionSubscribers() (userIDs []string)
// Notify sends a notification to the user
Notify(msg NotificationMessage)
}
type NotificationMessage struct {
// the type of the notification
Type NotificationType `json:"notification_type"`
// the receiver user id
ReceiverUserID string `json:"receiver_user_id"`
// the receiver user using language
ReceiverLang string `json:"receiver_lang"`
// the receiver user external id (optional)
ReceiverExternalID string `json:"receiver_external_id"`
// Who triggered the notification (optional, admin or system operation will not have this field)
TriggerUserID string `json:"trigger_user_id"`
// The trigger user's display name (optional, admin or system operation will not have this field)
TriggerUserDisplayName string `json:"trigger_user_display_name"`
// The trigger user's url (optional, admin or system operation will not have this field)
TriggerUserUrl string `json:"trigger_user_url"`
// the question title
QuestionTitle string `json:"question_title"`
// the question url
QuestionUrl string `json:"question_url"`
// the question tags (comma separated, optional, only for new question notification)
QuestionTags string `json:"tags"`
// the answer url (optional, only for new answer notification)
AnswerUrl string `json:"answer_url"`
// the comment url (optional, only for new comment notification)
CommentUrl string `json:"comment_url"`
}
var (
// CallNotification is a function that calls all registered notification plugins
CallNotification,
registerNotification = MakePlugin[Notification](false)
)
+31
View File
@@ -0,0 +1,31 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin
type Parser interface {
Base
Parse(text string) (string, error)
}
var (
// CallParser is a function that calls all registered parsers
CallParser,
registerParser = MakePlugin[Parser](false)
)
+254
View File
@@ -0,0 +1,254 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin
import (
"encoding/json"
"sync"
"github.com/segmentfault/pacman/cache"
"github.com/segmentfault/pacman/i18n"
"xorm.io/xorm"
"github.com/apache/answer/internal/base/handler"
"github.com/apache/answer/internal/base/translator"
"github.com/gin-gonic/gin"
)
// Data is defined here to avoid circular dependency with internal/base/data
type Data struct {
DB *xorm.Engine
Cache cache.Cache
}
// GinContext is a wrapper of gin.Context
// We export it to make it easy to use in plugins
type GinContext = gin.Context
// StatusManager is a manager that manages the status of plugins
// Init Plugins:
// json.Unmarshal([]byte(`{"plugin1": true, "plugin2": false}`), &plugin.StatusManager)
// Dump Status:
// json.Marshal(plugin.StatusManager)
var StatusManager = statusManager{
status: make(map[string]bool),
}
// Register registers a plugin
func Register(p Base) {
registerBase(p)
if _, ok := p.(Config); ok {
registerConfig(p.(Config))
}
if _, ok := p.(UserConfig); ok {
registerUserConfig(p.(UserConfig))
}
if _, ok := p.(Connector); ok {
registerConnector(p.(Connector))
}
if _, ok := p.(Parser); ok {
registerParser(p.(Parser))
}
if _, ok := p.(Filter); ok {
registerFilter(p.(Filter))
}
if _, ok := p.(Storage); ok {
registerStorage(p.(Storage))
}
if _, ok := p.(Cache); ok {
registerCache(p.(Cache))
}
if _, ok := p.(UserCenter); ok {
registerUserCenter(p.(UserCenter))
}
if _, ok := p.(Agent); ok {
registerAgent(p.(Agent))
}
if _, ok := p.(Search); ok {
registerSearch(p.(Search))
}
if _, ok := p.(Notification); ok {
registerNotification(p.(Notification))
}
if _, ok := p.(Reviewer); ok {
registerReviewer(p.(Reviewer))
}
if _, ok := p.(Captcha); ok {
registerCaptcha(p.(Captcha))
}
if _, ok := p.(Embed); ok {
registerEmbed(p.(Embed))
}
if _, ok := p.(Render); ok {
registerRender(p.(Render))
}
if _, ok := p.(CDN); ok {
registerCDN(p.(CDN))
}
if _, ok := p.(Importer); ok {
registerImporter(p.(Importer))
}
if _, ok := p.(KVStorage); ok {
registerKVStorage(p.(KVStorage))
}
if _, ok := p.(Sidebar); ok {
registerSidebar(p.(Sidebar))
}
if _, ok := p.(VectorSearch); ok {
registerVectorSearch(p.(VectorSearch))
}
}
type Stack[T Base] struct {
plugins []T
}
type RegisterFn[T Base] func(p T)
type Caller[T Base] func(p T) error
type CallFn[T Base] func(fn Caller[T]) error
// MakePlugin creates a plugin caller and register stack manager
// The parameter super presents if the plugin can be disabled.
// It returns a register function and a caller function
// The register function is used to register a plugin, it will be called in the plugin's init function
// The caller function is used to call all registered plugins
func MakePlugin[T Base](super bool) (CallFn[T], RegisterFn[T]) {
stack := Stack[T]{}
call := func(fn Caller[T]) error {
for _, p := range stack.plugins {
// If the plugin is disabled, skip it
if !super && !StatusManager.IsEnabled(p.Info().SlugName) {
continue
}
if err := fn(p); err != nil {
return err
}
}
return nil
}
register := func(p T) {
for _, plugin := range stack.plugins {
if plugin.Info().SlugName == p.Info().SlugName {
panic("plugin " + p.Info().SlugName + " is already registered")
}
}
stack.plugins = append(stack.plugins, p)
}
return call, register
}
type statusManager struct {
lock sync.Mutex
status map[string]bool
}
func (m *statusManager) Enable(name string, enabled bool) {
m.lock.Lock()
defer m.lock.Unlock()
if !enabled {
m.status[name] = enabled
return
}
m.status[name] = enabled
for _, slugName := range coordinatedCaptchaPlugins(name) {
m.status[slugName] = false
}
for _, slugName := range coordinatedCDNPlugins(name) {
m.status[slugName] = false
}
}
func (m *statusManager) IsEnabled(name string) bool {
if status, ok := m.status[name]; ok {
return status
}
return false
}
// MarshalJSON implements the json.Marshaler interface.
func (m *statusManager) MarshalJSON() ([]byte, error) {
return json.Marshal(m.status)
}
// UnmarshalJSON implements the json.Unmarshaler interface.
func (m *statusManager) UnmarshalJSON(data []byte) error {
return json.Unmarshal(data, &m.status)
}
// Translate translates the key to the current language of the context
func Translate(ctx *GinContext, key string) string {
return translator.Tr(handler.GetLangByCtx(ctx), key)
}
// TranslateWithData translates the key to the language with data
func TranslateWithData(lang i18n.Language, key string, data any) string {
return translator.TrWithData(lang, key, data)
}
// TranslateFn presents a generator of translated string.
// We use it to delegate the translation work outside the plugin.
type TranslateFn func(ctx *GinContext) string
// Translator contains a function that translates the key to the current language of the context
type Translator struct {
Fn TranslateFn
}
// MakeTranslator generates a translator from the key
func MakeTranslator(key string) Translator {
t := func(ctx *GinContext) string {
return Translate(ctx, key)
}
return Translator{Fn: t}
}
// Translate translates the key to the current language of the context
func (t Translator) Translate(ctx *GinContext) string {
if t.Fn == nil {
return ""
}
return t.Fn(ctx)
}
+412
View File
@@ -0,0 +1,412 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin_test
import (
"context"
"fmt"
"math/rand"
"sync"
"testing"
"time"
"github.com/apache/answer/plugin"
"github.com/segmentfault/pacman/log"
_ "modernc.org/sqlite"
)
var (
testPlugin *TestKVStoragePlugin
)
// Helper functions for testing
func mustSet(t *testing.T, kv *plugin.KVOperator, ctx context.Context, group, key, value string) {
if err := kv.Set(ctx, plugin.KVParams{Group: group, Key: key, Value: value}); err != nil {
t.Fatalf("Failed to set %s/%s: %v", group, key, err)
}
}
func mustGet(t *testing.T, kv *plugin.KVOperator, ctx context.Context, group, key, expected string) {
val, err := kv.Get(ctx, plugin.KVParams{Group: group, Key: key})
if err != nil {
t.Fatalf("Failed to get %s/%s: %v", group, key, err)
}
if val != expected {
t.Errorf("Expected '%s' for %s/%s, got '%s'", expected, group, key, val)
}
}
func mustDel(t *testing.T, kv *plugin.KVOperator, ctx context.Context, group, key string) {
if err := kv.Del(ctx, plugin.KVParams{Group: group, Key: key}); err != nil {
t.Fatalf("Failed to delete %s/%s: %v", group, key, err)
}
}
func assertNotFound(t *testing.T, kv *plugin.KVOperator, ctx context.Context, group, key string) {
val, err := kv.Get(ctx, plugin.KVParams{Group: group, Key: key})
if err != plugin.ErrKVKeyNotFound {
t.Errorf("Expected ErrKVKeyNotFound for %s/%s, got: %v", group, key, err)
}
if val != "" {
t.Errorf("Expected empty value for %s/%s, got: '%s'", group, key, val)
}
}
func assertError(t *testing.T, err error, expected error, msg string) {
if err != expected {
t.Errorf("%s: expected %v, got %v", msg, expected, err)
}
}
// TestKVStoragePlugin implements KVStorage interface for testing
type TestKVStoragePlugin struct {
operator *plugin.KVOperator
}
// Info returns plugin information
func (p *TestKVStoragePlugin) Info() plugin.Info {
return plugin.Info{
Name: plugin.MakeTranslator("test_kv_storage_name"),
SlugName: "test_kv_storage",
Description: plugin.MakeTranslator("test_kv_storage_desc"),
Author: "Answer Team",
Version: "1.0.0",
Link: "https://github.com/apache/answer",
}
}
// SetOperator sets KV operator
func (p *TestKVStoragePlugin) SetOperator(operator *plugin.KVOperator) {
p.operator = operator
}
// setupTestEnvironment sets up test environment
func setupTestEnvironment() {
// Initialize only once
if testPlugin != nil {
return
}
// Create and register test plugin
testPlugin = &TestKVStoragePlugin{}
plugin.Register(testPlugin)
// Enable plugin
plugin.StatusManager.Enable("test_kv_storage", true)
// Initialize plugin data, refer to plugin_common_service.go implementation
_ = plugin.CallKVStorage(func(k plugin.KVStorage) error {
k.SetOperator(plugin.NewKVOperator(
testDataSource.DB,
testDataSource.Cache,
k.Info().SlugName,
))
return nil
})
}
// Test basic operations including CRUD and edge cases
func TestBasicOperations(t *testing.T) {
setupTestEnvironment()
kv := testPlugin.operator
ctx := context.Background()
t.Run("BasicCRUD", func(t *testing.T) {
// Set/Get
mustSet(t, kv, ctx, "group1", "key1", "value1")
mustGet(t, kv, ctx, "group1", "key1", "value1")
// Update
mustSet(t, kv, ctx, "group1", "key1", "new_value")
mustGet(t, kv, ctx, "group1", "key1", "new_value")
// Delete
mustDel(t, kv, ctx, "group1", "key1")
assertNotFound(t, kv, ctx, "group1", "key1")
// Group operation
mustSet(t, kv, ctx, "group1", "key2", "value2")
mustSet(t, kv, ctx, "group1", "key3", "value3")
groupData, err := kv.GetByGroup(ctx, plugin.KVParams{Group: "group1", Page: 1, PageSize: 10})
if err != nil {
t.Fatalf("Failed to get group data: %v", err)
}
// the groupData should only have key2 and key3 because key1 is deleted
if len(groupData) != 2 {
t.Errorf("Expected 2 items, got %d", len(groupData))
}
if groupData["key2"] != "value2" || groupData["key3"] != "value3" {
t.Errorf("Unexpected group data: %v", groupData)
}
})
t.Run("EdgeCases", func(t *testing.T) {
// Empty key
err := kv.Set(ctx, plugin.KVParams{Group: "group", Key: "", Value: "value"})
assertError(t, err, plugin.ErrKVKeyEmpty, "Empty key test")
// Empty group query
_, err = kv.GetByGroup(ctx, plugin.KVParams{Group: "", Page: 1, PageSize: 10})
assertError(t, err, plugin.ErrKVGroupEmpty, "Empty group test")
// Non-existent key
assertNotFound(t, kv, ctx, "non_exist_group", "non_exist_key")
// Cache penetration protection
key := fmt.Sprintf("non_exist_key_%d", time.Now().UnixNano())
assertNotFound(t, kv, ctx, "cache_penetration", key)
})
t.Run("CacheConsistency", func(t *testing.T) {
mustSet(t, kv, ctx, "cache_group", "cache_key", "cache_value")
mustGet(t, kv, ctx, "cache_group", "cache_key", "cache_value")
// Update and verify immediate consistency
mustSet(t, kv, ctx, "cache_group", "cache_key", "updated_value")
mustGet(t, kv, ctx, "cache_group", "cache_key", "updated_value")
})
}
// Test transactions including rollback and nested transactions
func TestTransactions(t *testing.T) {
setupTestEnvironment()
kv := testPlugin.operator
ctx := context.Background()
t.Run("SuccessfulTransaction", func(t *testing.T) {
err := kv.Tx(ctx, func(ctx context.Context, txKv *plugin.KVOperator) error {
if err := txKv.Set(ctx, plugin.KVParams{Group: "tx_group", Key: "tx_key1", Value: "tx_value1"}); err != nil {
return err
}
if err := txKv.Set(ctx, plugin.KVParams{Group: "tx_group", Key: "tx_key2", Value: "tx_value2"}); err != nil {
return err
}
return nil
})
if err != nil {
t.Fatalf("Successful transaction failed: %v", err)
}
mustGet(t, kv, ctx, "tx_group", "tx_key1", "tx_value1")
mustGet(t, kv, ctx, "tx_group", "tx_key2", "tx_value2")
})
t.Run("TransactionRollback", func(t *testing.T) {
err := kv.Tx(ctx, func(ctx context.Context, txKv *plugin.KVOperator) error {
if err := txKv.Set(ctx, plugin.KVParams{Group: "tx_group", Key: "tx_key3", Value: "tx_value3"}); err != nil {
return err
}
return fmt.Errorf("mock error")
})
if err == nil {
t.Error("Expected transaction to fail but it succeeded")
}
assertNotFound(t, kv, ctx, "tx_group", "tx_key3")
})
t.Run("NestedTransactions", func(t *testing.T) {
err := kv.Tx(ctx, func(ctx context.Context, txKv *plugin.KVOperator) error {
if err := txKv.Set(ctx, plugin.KVParams{Group: "nested", Key: "key1", Value: "value1"}); err != nil {
return err
}
return txKv.Tx(ctx, func(ctx context.Context, nestedKv *plugin.KVOperator) error {
if err := nestedKv.Set(ctx, plugin.KVParams{Group: "nested", Key: "key2", Value: "value2"}); err != nil {
return err
}
return fmt.Errorf("mock nested error")
})
})
if err == nil {
t.Error("Expected nested transaction to fail but it succeeded")
}
// Verify outer transaction also rolled back
assertNotFound(t, kv, ctx, "nested", "key1")
assertNotFound(t, kv, ctx, "nested", "key2")
})
}
// Test pagination in GetByGroup
func TestPagination(t *testing.T) {
setupTestEnvironment()
kv := testPlugin.operator
ctx := context.Background()
totalItems := 25
for i := range totalItems {
mustSet(t, kv, ctx, "pagination", fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i))
}
// Test pagination
page1, err := kv.GetByGroup(ctx, plugin.KVParams{Group: "pagination", Page: 1, PageSize: 10})
if err != nil {
t.Fatalf("Failed to get page 1: %v", err)
}
if len(page1) != 10 {
t.Errorf("Page 1: expected 10 items, got %d", len(page1))
}
page2, err := kv.GetByGroup(ctx, plugin.KVParams{Group: "pagination", Page: 2, PageSize: 10})
if err != nil {
t.Fatalf("Failed to get page 2: %v", err)
}
if len(page2) != 10 {
t.Errorf("Page 2: expected 10 items, got %d", len(page2))
}
page3, err := kv.GetByGroup(ctx, plugin.KVParams{Group: "pagination", Page: 3, PageSize: 10})
if err != nil {
t.Fatalf("Failed to get page 3: %v", err)
}
if len(page3) != 5 {
t.Errorf("Page 3: expected 5 items, got %d", len(page3))
}
// Verify different keys on different pages
for i := range 10 {
key := fmt.Sprintf("key%d", i)
if _, ok := page1[key]; !ok {
t.Errorf("Pagination test failed, key %s should be on page 1", key)
}
}
for i := range 10 {
key := fmt.Sprintf("key%d", i+10)
if _, ok := page2[key]; !ok {
t.Errorf("Pagination test failed, key %s should be on page 2", key)
}
}
}
// Test concurrent operations and performance
func TestConcurrency(t *testing.T) {
setupTestEnvironment()
kv := testPlugin.operator
ctx := context.Background()
t.Run("BasicConcurrency", func(t *testing.T) {
parallel := 10
var wg sync.WaitGroup
wg.Add(parallel)
for i := range parallel {
go func(index int) {
defer wg.Done()
time.Sleep(time.Duration(rand.Intn(200)) * time.Millisecond)
mustSet(t, kv, ctx, "concurrent", fmt.Sprintf("key%d", index), "value")
}(i)
}
wg.Wait()
// Verify results
wg.Add(parallel)
for i := range parallel {
go func(index int) {
defer wg.Done()
time.Sleep(time.Duration(rand.Intn(200)) * time.Millisecond)
mustGet(t, kv, ctx, "concurrent", fmt.Sprintf("key%d", index), "value")
}(i)
}
wg.Wait()
})
t.Run("StressTest", func(t *testing.T) {
if testing.Short() {
t.Skip("Skipping stress test in short mode")
}
totalOps := 1000
workerCount := 20
prefix := "stress_test"
opsPerWorker := totalOps / workerCount
log.Info("Starting KV storage stress test...")
startTime := time.Now()
// Concurrent write test
var wg sync.WaitGroup
errorCount := int64(0)
for w := range workerCount {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
startIdx := workerID * opsPerWorker
for i := range opsPerWorker {
i := startIdx + i
err := kv.Set(ctx, plugin.KVParams{
Group: prefix,
Key: fmt.Sprintf("key%d", i),
Value: fmt.Sprintf("value%d", i),
})
if err != nil {
log.Warnf("Write error: %v", err)
errorCount++
}
}
}(w)
}
wg.Wait()
writeTime := time.Since(startTime)
// Verify data integrity
groupData, err := kv.GetByGroup(ctx, plugin.KVParams{Group: prefix, Page: 1, PageSize: totalOps})
if err != nil {
t.Fatalf("Failed to verify data: %v", err)
}
if len(groupData) != totalOps {
t.Errorf("Data loss: expected %d items, got %d", totalOps, len(groupData))
}
// Concurrent read test
startTime = time.Now()
readErrors := int64(0)
wg.Add(workerCount)
for range workerCount {
go func() {
defer wg.Done()
for range opsPerWorker {
keyIdx := rand.Intn(totalOps)
key := fmt.Sprintf("key%d", keyIdx)
expected := fmt.Sprintf("value%d", keyIdx)
val, err := kv.Get(ctx, plugin.KVParams{Group: prefix, Key: key})
if err != nil {
readErrors++
} else if val != expected {
t.Errorf("Data inconsistency: key=%s, expected=%s, got=%s", key, expected, val)
}
}
}()
}
wg.Wait()
readTime := time.Since(startTime)
log.Infof("Stress test completed:")
log.Infof(" Write: %d ops in %v (%.1f ops/sec), %d errors", totalOps, writeTime, float64(totalOps)/writeTime.Seconds(), errorCount)
log.Infof(" Read: %d ops in %v (%.1f ops/sec), %d errors", totalOps, readTime, float64(totalOps)/readTime.Seconds(), readErrors)
})
}
+204
View File
@@ -0,0 +1,204 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin_test
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/apache/answer/internal/base/data"
"github.com/apache/answer/internal/migrations"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
"github.com/segmentfault/pacman/cache"
"github.com/segmentfault/pacman/log"
"xorm.io/xorm"
"xorm.io/xorm/schemas"
)
var (
mysqlDBSetting = TestDBSetting{
Driver: string(schemas.MYSQL),
ImageName: "mariadb",
ImageVersion: "10.4.7",
ENV: []string{"MYSQL_ROOT_PASSWORD=root", "MYSQL_DATABASE=answer", "MYSQL_ROOT_HOST=%"},
PortID: "3306/tcp",
Connection: "root:root@(localhost:%s)/answer?parseTime=true", // port is not fixed, it will be got by port id
}
postgresDBSetting = TestDBSetting{
Driver: string(schemas.POSTGRES),
ImageName: "postgres",
ImageVersion: "14",
ENV: []string{"POSTGRES_USER=root", "POSTGRES_PASSWORD=root", "POSTGRES_DB=answer", "LISTEN_ADDRESSES='*'"},
PortID: "5432/tcp",
Connection: "host=localhost port=%s user=root password=root dbname=answer sslmode=disable",
}
sqlite3DBSetting = TestDBSetting{
Driver: string(schemas.SQLITE),
Connection: filepath.Join(os.TempDir(), "answer-test-data.db"),
}
dbSettingMapping = map[string]TestDBSetting{
mysqlDBSetting.Driver: mysqlDBSetting,
sqlite3DBSetting.Driver: sqlite3DBSetting,
postgresDBSetting.Driver: postgresDBSetting,
}
// after all test down will execute tearDown function to clean-up
tearDown func()
// testDataSource used for repo testing
testDataSource *data.Data
testCache cache.Cache
)
func TestMain(t *testing.M) {
dbSetting, ok := dbSettingMapping[os.Getenv("TEST_DB_DRIVER")]
if !ok {
// Use sqlite3 to test.
dbSetting = dbSettingMapping[string(schemas.SQLITE)]
}
if dbSetting.Driver == string(schemas.SQLITE) {
_ = os.RemoveAll(dbSetting.Connection)
}
if err := initTestDataSource(dbSetting); err != nil {
panic(err)
}
log.Info("init test database successfully")
ret := t.Run()
if tearDown != nil {
tearDown()
}
os.Exit(ret)
}
type TestDBSetting struct {
Driver string
ImageName string
ImageVersion string
ENV []string
PortID string
Connection string
}
func initTestDataSource(dbSetting TestDBSetting) error {
connection, imageCleanUp, err := initDatabaseImage(dbSetting)
if err != nil {
return err
}
dbSetting.Connection = connection
dbEngine, err := initDatabase(dbSetting)
if err != nil {
return err
}
newCache, err := initCache()
if err != nil {
return err
}
newData, dbCleanUp, err := data.NewData(dbEngine, newCache)
if err != nil {
return err
}
testDataSource = newData
testCache = newCache
tearDown = func() {
dbCleanUp()
log.Info("cleanup test database successfully")
imageCleanUp()
log.Info("cleanup test database image successfully")
}
return nil
}
func initDatabaseImage(dbSetting TestDBSetting) (connection string, cleanup func(), err error) {
// sqlite3 don't need to set up image
if dbSetting.Driver == string(schemas.SQLITE) {
return dbSetting.Connection, func() {
log.Info("remove database", dbSetting.Connection)
err = os.Remove(dbSetting.Connection)
if err != nil {
log.Error(err)
}
}, nil
}
pool, err := dockertest.NewPool("")
pool.MaxWait = time.Minute * 5
if err != nil {
return "", nil, fmt.Errorf("could not connect to docker: %s", err)
}
// resource, err := pool.Run(dbSetting.ImageName, dbSetting.ImageVersion, dbSetting.ENV)
resource, err := pool.RunWithOptions(&dockertest.RunOptions{
Repository: dbSetting.ImageName,
Tag: dbSetting.ImageVersion,
Env: dbSetting.ENV,
}, func(config *docker.HostConfig) {
config.AutoRemove = true
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
})
if err != nil {
return "", nil, fmt.Errorf("could not pull resource: %s", err)
}
connection = fmt.Sprintf(dbSetting.Connection, resource.GetPort(dbSetting.PortID))
if err := pool.Retry(func() error {
db, err := sql.Open(dbSetting.Driver, connection)
if err != nil {
return err
}
return db.Ping()
}); err != nil {
return "", nil, fmt.Errorf("could not connect to database: %s", err)
}
return connection, func() { _ = pool.Purge(resource) }, nil
}
func initDatabase(dbSetting TestDBSetting) (dbEngine *xorm.Engine, err error) {
dataConf := &data.Database{Driver: dbSetting.Driver, Connection: dbSetting.Connection}
dbEngine, err = data.NewDB(true, dataConf)
if err != nil {
return nil, fmt.Errorf("connection to database failed: %s", err)
}
if err := migrations.NewMentor(context.TODO(), dbEngine, &migrations.InitNeedUserInputData{
Language: "en_US",
SiteName: "ANSWER",
SiteURL: "http://127.0.0.1:8080/",
ContactEmail: "answer@answer.com",
AdminName: "admin",
AdminPassword: "admin",
AdminEmail: "answer@answer.com",
}).InitDB(); err != nil {
return nil, fmt.Errorf("migrations init database failed: %s", err)
}
return dbEngine, nil
}
func initCache() (newCache cache.Cache, err error) {
newCache, _, err = data.NewCache(&data.CacheConf{})
return newCache, err
}
+39
View File
@@ -0,0 +1,39 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin
import "github.com/gin-gonic/gin"
type RenderConfig struct {
SelectTheme string `json:"select_theme"`
}
// select_theme
type Render interface {
Base
GetRenderConfig(ctx *gin.Context) (renderConfig *RenderConfig)
}
var (
// CallRender is a function that calls all registered parsers
CallRender,
registerRender = MakePlugin[Render](false)
)
+81
View File
@@ -0,0 +1,81 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin
type Reviewer interface {
Base
Review(content *ReviewContent) (result *ReviewResult)
}
// ReviewContent is a struct that contains the content of a review
type ReviewContent struct {
// The type of the content, e.g. question, answer
ObjectType string
// The title of the content, only available for the question
Title string
// The content of the review, always available
Content string
// The tags of the content, only available for the question
Tags []string
// The author of the content
Author ReviewContentAuthor
// Review Language, the site language. e.g. en_US
// The plugin may reply the review result according to the language
Language string
// The user agent of the request web browser
UserAgent string
// The IP address of the request
IP string
}
type ReviewContentAuthor struct {
// The user's reputation
Rank int
// The amount of questions that has approved
ApprovedQuestionAmount int64
// The amount of answers that has approved
ApprovedAnswerAmount int64
// 1:User 2:Admin 3:Moderator
Role int
}
type ReviewStatus string
const (
ReviewStatusApproved ReviewStatus = "approved"
ReviewStatusDeleteDirectly ReviewStatus = "delete_directly"
ReviewStatusNeedReview ReviewStatus = "need_review"
)
// ReviewResult is a struct that contains the result of a review
type ReviewResult struct {
// If the review is approved
Approved bool
// The status of the review
ReviewStatus ReviewStatus
// The reason for the result
Reason string
}
var (
// CallReviewer is a function that calls all registered parsers
CallReviewer,
registerReviewer = MakePlugin[Reviewer](false)
)
+130
View File
@@ -0,0 +1,130 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin
import (
"context"
)
type SearchResult struct {
// ID content ID
ID string
// Type content type, example: "answer", "question"
Type string
}
type SearchContent struct {
ObjectID string `json:"objectID"`
Title string `json:"title"`
Type string `json:"type"`
Content string `json:"content"`
Answers int64 `json:"answers"`
Status SearchContentStatus `json:"status"`
Tags []string `json:"tags"`
QuestionID string `json:"questionID"`
UserID string `json:"userID"`
Views int64 `json:"views"`
Created int64 `json:"created"`
Active int64 `json:"active"`
Score int64 `json:"score"`
HasAccepted bool `json:"hasAccepted"`
}
type SearchBasicCond struct {
// From zero-based page number
Page int
// Page size
PageSize int
// The keywords for search.
Words []string
// TagIDs is a list of tag IDs.
TagIDs [][]string
// The object's owner user ID.
UserID string
// The order of the search result.
Order SearchOrderCond
// Weathers the question is accepted or not. Only support search question.
QuestionAccepted SearchAcceptedCond
// Weathers the answer is accepted or not. Only support search answer.
AnswerAccepted SearchAcceptedCond
// Only support search answer.
QuestionID string
// greater than or equal to the number of votes.
VoteAmount int
// greater than or equal to the number of views.
ViewAmount int
// greater than or equal to the number of answers. Only support search question.
AnswerAmount int
}
type SearchAcceptedCond int
type SearchContentStatus int
type SearchOrderCond string
const (
AcceptedCondAll SearchAcceptedCond = iota
AcceptedCondTrue
AcceptedCondFalse
)
const (
SearchContentStatusAvailable = 1
SearchContentStatusDeleted = 10
)
const (
SearchNewestOrder SearchOrderCond = "newest"
SearchActiveOrder SearchOrderCond = "active"
SearchScoreOrder SearchOrderCond = "score"
SearchRelevanceOrder SearchOrderCond = "relevance"
)
type Search interface {
Base
Description() SearchDesc
RegisterSyncer(ctx context.Context, syncer SearchSyncer)
SearchContents(ctx context.Context, cond *SearchBasicCond) (res []SearchResult, total int64, err error)
SearchQuestions(ctx context.Context, cond *SearchBasicCond) (res []SearchResult, total int64, err error)
SearchAnswers(ctx context.Context, cond *SearchBasicCond) (res []SearchResult, total int64, err error)
UpdateContent(ctx context.Context, content *SearchContent) (err error)
DeleteContent(ctx context.Context, objectID string) (err error)
}
type SearchDesc struct {
// A svg icon it wil be display in search result page. optional
Icon string `json:"icon"`
// The link address of the search engine. optional
Link string `json:"link"`
}
type SearchSyncer interface {
GetAnswersPage(ctx context.Context, page, pageSize int) (answerList []*SearchContent, err error)
GetQuestionsPage(ctx context.Context, page, pageSize int) (questionList []*SearchContent, err error)
}
var (
// CallSearch is a function that calls all registered parsers
CallSearch,
registerSearch = MakePlugin[Search](false)
)
+37
View File
@@ -0,0 +1,37 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// Package plugin
package plugin
type SidebarConfig struct {
Tags []*TagSelectorOption `json:"tags"`
LinksText string `json:"links_text"`
}
type Sidebar interface {
Base
GetSidebarConfig() (sidebarConfig *SidebarConfig, err error)
}
var (
// CallRender is a function that calls all registered parsers
CallSidebar,
registerSidebar = MakePlugin[Sidebar](false)
)
+91
View File
@@ -0,0 +1,91 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin
type UploadSource string
const (
UserAvatar UploadSource = "user_avatar"
UserPost UploadSource = "user_post"
UserPostAttachment UploadSource = "user_post_attachment"
AdminBranding UploadSource = "admin_branding"
)
var (
DefaultFileTypeCheckMapping = map[UploadSource]map[string]bool{
UserAvatar: {
".jpg": true,
".jpeg": true,
".png": true,
".webp": true,
},
UserPost: {
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".webp": true,
},
AdminBranding: {
".jpg": true,
".jpeg": true,
".png": true,
".ico": true,
},
}
)
type UploadFileCondition struct {
// Source is the source of the file
Source UploadSource
// MaxImageSize is the maximum size of the image in MB
MaxImageSize int
// MaxAttachmentSize is the maximum size of the attachment in MB
MaxAttachmentSize int
// MaxImageMegapixel is the maximum megapixel of the image
MaxImageMegapixel int
// AuthorizedImageExtensions is the list of authorized image extensions
AuthorizedImageExtensions []string
// AuthorizedAttachmentExtensions is the list of authorized attachment extensions
AuthorizedAttachmentExtensions []string
}
type UploadFileResponse struct {
// FullURL is the URL that can be used to access the file
FullURL string
// OriginalError is the error returned by the storage plugin. It is used for debugging.
OriginalError error
// DisplayErrorMsg is the error message that will be displayed to the user.
DisplayErrorMsg Translator
}
type Storage interface {
Base
// UploadFile uploads a file to storage.
// The file is in the Form of the ctx and the key is "file"
UploadFile(ctx *GinContext, condition UploadFileCondition) UploadFileResponse
}
var (
// CallStorage is a function that calls all registered storage
CallStorage,
registerStorage = MakePlugin[Storage](false)
)
+127
View File
@@ -0,0 +1,127 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin
type UserCenter interface {
Base
// Description returns the description of the user center, including the name, icon, url, etc.
Description() UserCenterDesc
// ControlCenterItems returns the items that will be displayed in the control center
ControlCenterItems() []ControlCenter
// LoginCallback is called when the user center login callback is called
LoginCallback(ctx *GinContext) (userInfo *UserCenterBasicUserInfo, err error)
// SignUpCallback is called when the user center sign up callback is called
SignUpCallback(ctx *GinContext) (userInfo *UserCenterBasicUserInfo, err error)
// UserInfo returns the user information
UserInfo(externalID string) (userInfo *UserCenterBasicUserInfo, err error)
// UserStatus returns the latest user status
UserStatus(externalID string) (userStatus UserStatus)
// UserList returns the user list information
UserList(externalIDs []string) (userInfo []*UserCenterBasicUserInfo, err error)
// UserSettings returns the user settings
UserSettings(externalID string) (userSettings *SettingInfo, err error)
// PersonalBranding returns the personal branding information
PersonalBranding(externalID string) (branding []*PersonalBranding)
// AfterLogin is called after the user logs in
AfterLogin(externalID, accessToken string)
}
type UserCenterDesc struct {
Name string `json:"name"`
DisplayName Translator `json:"display_name"`
Icon string `json:"icon"`
Url string `json:"url"`
LoginRedirectURL string `json:"login_redirect_url"`
SignUpRedirectURL string `json:"sign_up_redirect_url"`
RankAgentEnabled bool `json:"rank_agent_enabled"`
UserStatusAgentEnabled bool `json:"user_status_agent_enabled"`
UserRoleAgentEnabled bool `json:"user_role_agent_enabled"`
MustAuthEmailEnabled bool `json:"must_auth_email_enabled"`
EnabledOriginalUserSystem bool `json:"enabled_original_user_system"`
}
type UserStatus int
const (
UserStatusAvailable UserStatus = 1
UserStatusSuspended UserStatus = 9
UserStatusDeleted UserStatus = 10
)
type UserCenterBasicUserInfo struct {
ExternalID string `json:"external_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Rank int `json:"rank"`
Avatar string `json:"avatar"`
Mobile string `json:"mobile"`
Bio string `json:"bio"`
Status UserStatus `json:"status"`
}
type ControlCenter struct {
Name string `json:"name"`
Label string `json:"label"`
Url string `json:"url"`
}
type SettingInfo struct {
ProfileSettingRedirectURL string `json:"profile_setting_redirect_url"`
AccountSettingRedirectURL string `json:"account_setting_redirect_url"`
}
type PersonalBranding struct {
Icon string `json:"icon"`
Name string `json:"name"`
Label string `json:"label"`
Url string `json:"url"`
}
var (
// CallUserCenter is a function that calls all registered parsers
CallUserCenter,
registerUserCenter = MakePlugin[UserCenter](false)
)
func UserCenterEnabled() (enabled bool) {
_ = CallUserCenter(func(fn UserCenter) error {
enabled = true
return nil
})
return
}
func RankAgentEnabled() (enabled bool) {
_ = CallUserCenter(func(fn UserCenter) error {
enabled = fn.Description().RankAgentEnabled
return nil
})
return
}
func GetUserCenter() (uc UserCenter, ok bool) {
_ = CallUserCenter(func(fn UserCenter) error {
uc = fn
ok = true
return nil
})
return
}
+52
View File
@@ -0,0 +1,52 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin
type UserConfig interface {
Base
// UserConfigFields returns the list of config fields
UserConfigFields() []ConfigField
// UserConfigReceiver receives the config data, it calls when the config is saved or initialized.
// We recommend to unmarshal the data to a struct, and then use the struct to do something.
// The config is encoded in JSON format.
// It depends on the definition of ConfigFields.
UserConfigReceiver(userID string, config []byte) error
}
var (
// CallUserConfig is a function that calls all registered config plugins
CallUserConfig,
registerUserConfig = MakePlugin[UserConfig](false)
getPluginUserConfigFn func(userID, pluginSlugName string) []byte
)
// GetPluginUserConfig returns the user config of the given user id
func GetPluginUserConfig(userID, pluginSlugName string) []byte {
if getPluginUserConfigFn != nil {
return getPluginUserConfigFn(userID, pluginSlugName)
}
return nil
}
// RegisterGetPluginUserConfigFunc registers a function to get the user config of the given user id
func RegisterGetPluginUserConfigFunc(fn func(userID, pluginSlugName string) []byte) {
getPluginUserConfigFn = fn
}
+174
View File
@@ -0,0 +1,174 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package plugin
import (
"context"
"fmt"
"strings"
"github.com/sashabaranov/go-openai"
"github.com/segmentfault/pacman/log"
)
// VectorSearchResult holds a single similarity search result returned by a VectorSearch plugin.
type VectorSearchResult struct {
// ObjectID is the unique identifier of the matched object (question ID or answer ID).
ObjectID string `json:"object_id"`
// ObjectType is "question" or "answer".
ObjectType string `json:"object_type"`
// Metadata is a JSON string containing VectorSearchMetadata for link composition and content retrieval.
Metadata string `json:"metadata"`
// Score is the cosine similarity score (0-1).
Score float64 `json:"score"`
}
// VectorSearchContent is the document structure passed to plugins for indexing.
type VectorSearchContent struct {
// ObjectID is the unique identifier (question ID or answer ID).
ObjectID string `json:"objectID"`
// ObjectType is "question" or "answer".
ObjectType string `json:"objectType"`
// Title is the question title.
Title string `json:"title"`
// Content is the aggregated text to be embedded (question body + answers + comments).
Content string `json:"content"`
// Metadata is a JSON string containing VectorSearchMetadata.
Metadata string `json:"metadata"`
}
// VectorSearchDesc describes the vector search engine for display purposes.
type VectorSearchDesc struct {
// Icon is an SVG icon for display. Optional.
Icon string `json:"icon"`
// Link is the URL of the vector search engine. Optional.
Link string `json:"link"`
}
// VectorSearchMetadata holds IDs for URI composition and content retrieval at query time.
// Shared between plugins and the core MCP controller.
type VectorSearchMetadata struct {
QuestionID string `json:"question_id"`
AnswerID string `json:"answer_id,omitempty"`
Answers []VectorSearchMetadataAnswer `json:"answers,omitempty"`
Comments []VectorSearchMetadataComment `json:"comments,omitempty"`
}
// VectorSearchMetadataAnswer stores answer ID and its comment IDs in metadata.
type VectorSearchMetadataAnswer struct {
AnswerID string `json:"answer_id"`
Comments []VectorSearchMetadataComment `json:"comments,omitempty"`
}
// VectorSearchMetadataComment stores a comment ID in metadata.
type VectorSearchMetadataComment struct {
CommentID string `json:"comment_id"`
}
// VectorSearch is the plugin interface for vector/semantic search engines.
// Plugins implementing this interface manage their own vector storage, embedding computation,
// data synchronization schedule, and similarity search.
type VectorSearch interface {
Base
// Description returns metadata about the vector search engine.
Description() VectorSearchDesc
// RegisterSyncer is called by the core to provide a data syncer.
// The plugin should store the syncer and use it to bulk-sync content
// (typically in a background goroutine).
RegisterSyncer(ctx context.Context, syncer VectorSearchSyncer)
// SearchSimilar performs a semantic similarity search.
// The plugin is responsible for embedding the query text and searching its vector store.
// Returns up to topK results sorted by similarity score (descending).
SearchSimilar(ctx context.Context, query string, topK int) ([]VectorSearchResult, error)
// UpdateContent upserts a single document in the vector store.
// Called by the core on incremental content changes.
UpdateContent(ctx context.Context, content *VectorSearchContent) error
// DeleteContent removes a document from the vector store by object ID.
DeleteContent(ctx context.Context, objectID string) error
}
// VectorSearchSyncer is implemented by the core and provided to plugins via RegisterSyncer.
// Plugins call these methods to pull all content for bulk indexing.
type VectorSearchSyncer interface {
// GetQuestionsPage returns a page of questions with aggregated text (title + body + answers + comments).
GetQuestionsPage(ctx context.Context, page, pageSize int) ([]*VectorSearchContent, error)
// GetAnswersPage returns a page of answers with aggregated text (answer body + parent question title + comments).
GetAnswersPage(ctx context.Context, page, pageSize int) ([]*VectorSearchContent, error)
}
var (
// CallVectorSearch is a function that calls all registered VectorSearch plugins.
CallVectorSearch,
registerVectorSearch = MakePlugin[VectorSearch](false)
)
// GenerateEmbedding is a base utility function that generates an embedding vector
// using an OpenAI-compatible API. Plugins that don't have a built-in vectorizer
// (most vector databases) can call this function with their own credentials.
// Plugins with built-in vectorizers (e.g., Weaviate) can skip this and use their own.
//
// Parameters:
// - ctx: context for cancellation
// - apiHost: the API base URL (e.g. "https://api.openai.com"); "/v1" is appended if missing
// - apiKey: the API key for authentication
// - model: the embedding model name (e.g. "text-embedding-3-small")
// - text: the text to embed
//
// Returns the embedding vector as []float32, or an error.
func GenerateEmbedding(ctx context.Context, apiHost, apiKey, model, text string) ([]float32, error) {
if model == "" {
return nil, fmt.Errorf("embedding model is not configured")
}
if text == "" {
return nil, fmt.Errorf("text is empty")
}
config := openai.DefaultConfig(apiKey)
config.BaseURL = apiHost
if !strings.HasSuffix(config.BaseURL, "/v1") {
config.BaseURL += "/v1"
}
log.Debugf("embedding: requesting model=%s baseURL=%s textLen=%d", model, config.BaseURL, len(text))
client := openai.NewClientWithConfig(config)
resp, err := client.CreateEmbeddings(ctx, openai.EmbeddingRequestStrings{
Input: []string{text},
Model: openai.EmbeddingModel(model),
})
if err != nil {
log.Errorf("embedding: request failed model=%s baseURL=%s err=%v", model, config.BaseURL, err)
return nil, fmt.Errorf("create embeddings failed: %w", err)
}
if len(resp.Data) == 0 {
log.Errorf("embedding: no data returned model=%s baseURL=%s", model, config.BaseURL)
return nil, fmt.Errorf("no embedding returned")
}
log.Debugf("embedding: success model=%s dimensions=%d usage={prompt=%d,total=%d}",
model, len(resp.Data[0].Embedding), resp.Usage.PromptTokens, resp.Usage.TotalTokens)
return resp.Data[0].Embedding, nil
}