Browse Source

上报记录

MortyFx 4 years ago
parent
commit
3c808fad6a

+ 2 - 0
.gitignore

@@ -0,0 +1,2 @@
+speedlogs
+.idea

+ 6 - 0
backend/SleekDB/Exceptions/ConditionNotAllowedException.php

@@ -0,0 +1,6 @@
+<?php
+
+
+namespace SleekDB\Exceptions;
+
+class ConditionNotAllowedException extends \Exception {}

+ 6 - 0
backend/SleekDB/Exceptions/EmptyConditionException.php

@@ -0,0 +1,6 @@
+<?php
+
+
+namespace SleekDB\Exceptions;
+
+class EmptyConditionException extends \Exception {}

+ 7 - 0
backend/SleekDB/Exceptions/EmptyFieldNameException.php

@@ -0,0 +1,7 @@
+<?php
+
+
+namespace SleekDB\Exceptions;
+
+
+class EmptyFieldNameException extends \Exception {}

+ 6 - 0
backend/SleekDB/Exceptions/EmptyStoreDataException.php

@@ -0,0 +1,6 @@
+<?php
+
+
+namespace SleekDB\Exceptions;
+
+class EmptyStoreDataException extends \Exception {}

+ 6 - 0
backend/SleekDB/Exceptions/EmptyStoreNameException.php

@@ -0,0 +1,6 @@
+<?php
+
+
+namespace SleekDB\Exceptions;
+
+class EmptyStoreNameException extends \Exception {}

+ 7 - 0
backend/SleekDB/Exceptions/IOException.php

@@ -0,0 +1,7 @@
+<?php
+
+
+namespace SleekDB\Exceptions;
+
+
+class IOException extends \Exception {}

+ 7 - 0
backend/SleekDB/Exceptions/IdNotAllowedException.php

@@ -0,0 +1,7 @@
+<?php
+
+
+namespace SleekDB\Exceptions;
+
+
+class IdNotAllowedException extends \Exception {}

+ 7 - 0
backend/SleekDB/Exceptions/IndexNotFoundException.php

@@ -0,0 +1,7 @@
+<?php
+
+
+namespace SleekDB\Exceptions;
+
+
+class IndexNotFoundException extends \Exception {}

+ 6 - 0
backend/SleekDB/Exceptions/InvalidArgumentException.php

@@ -0,0 +1,6 @@
+<?php
+
+
+namespace SleekDB\Exceptions;
+
+class InvalidArgumentException extends \Exception {}

+ 6 - 0
backend/SleekDB/Exceptions/InvalidConfigurationException.php

@@ -0,0 +1,6 @@
+<?php
+
+
+namespace SleekDB\Exceptions;
+
+class InvalidConfigurationException extends \Exception {}

+ 6 - 0
backend/SleekDB/Exceptions/InvalidDataException.php

@@ -0,0 +1,6 @@
+<?php
+
+
+namespace SleekDB\Exceptions;
+
+class InvalidDataException extends \Exception {}

+ 6 - 0
backend/SleekDB/Exceptions/InvalidOrderException.php

@@ -0,0 +1,6 @@
+<?php
+
+
+namespace SleekDB\Exceptions;
+
+class InvalidOrderException extends \Exception {}

+ 6 - 0
backend/SleekDB/Exceptions/InvalidStoreDataException.php

@@ -0,0 +1,6 @@
+<?php
+
+
+namespace SleekDB\Exceptions;
+
+class InvalidStoreDataException extends \Exception {}

+ 9 - 0
backend/SleekDB/Exceptions/JsonException.php

@@ -0,0 +1,9 @@
+<?php
+
+
+namespace SleekDB\Exceptions;
+
+
+if(!class_exists('JsonException')){
+    class JsonException extends \Exception {}
+}

+ 262 - 0
backend/SleekDB/SleekDB.php

@@ -0,0 +1,262 @@
+<?php
+
+  namespace SleekDB;
+
+  use SleekDB\Exceptions\ConditionNotAllowedException;
+  use SleekDB\Exceptions\EmptyFieldNameException;
+  use SleekDB\Exceptions\EmptyStoreDataException;
+  use SleekDB\Exceptions\EmptyStoreNameException;
+  use SleekDB\Exceptions\IdNotAllowedException;
+  use SleekDB\Exceptions\IndexNotFoundException;
+  use SleekDB\Exceptions\InvalidConfigurationException;
+  use SleekDB\Exceptions\InvalidDataException;
+  use SleekDB\Exceptions\InvalidStoreDataException;
+  use SleekDB\Exceptions\IOException;
+  use SleekDB\Exceptions\JsonException;
+
+  use SleekDB\Traits\HelperTrait;
+  use SleekDB\Traits\CacheTrait;
+  use SleekDB\Traits\ConditionTrait;
+
+  // to provide usage without composer, we need to require the files
+  require_once __DIR__ . "/Exceptions/ConditionNotAllowedException.php";
+  require_once __DIR__ . "/Exceptions/EmptyFieldNameException.php";
+  require_once __DIR__ . "/Exceptions/EmptyStoreNameException.php";
+  require_once __DIR__ . "/Exceptions/IdNotAllowedException.php";
+  require_once __DIR__ . "/Exceptions/IndexNotFoundException.php";
+  require_once __DIR__ . "/Exceptions/InvalidConfigurationException.php";
+  require_once __DIR__ . "/Exceptions/InvalidDataException.php";
+  require_once __DIR__ . "/Exceptions/InvalidStoreDataException.php";
+  require_once __DIR__ . "/Exceptions/IOException.php";
+  require_once __DIR__ . "/Exceptions/JsonException.php";
+
+  require_once __DIR__ . "/Traits/HelperTrait.php";
+  require_once __DIR__ . "/Traits/CacheTrait.php";
+  require_once __DIR__ . "/Traits/ConditionTrait.php";
+
+
+  class SleekDB{
+
+    use HelperTrait;
+    use ConditionTrait;
+    use CacheTrait;
+
+    private $root = __DIR__;
+
+    private $storeName;
+
+    private $makeCache;
+    private $useCache;
+
+    private $deleteCacheOnCreate;
+
+    private $storePath;
+
+    private $dataDirectory;
+    private $shouldKeepConditions;
+    private $results;
+    private $limit;
+    private $skip;
+    private $conditions;
+    private $orConditions;
+    private $in;
+    private $notIn;
+    private $orderBy;
+    private $searchKeyword;
+
+    private $fieldsToSelect = [];
+    private $fieldsToExclude = [];
+    private $orConditionsWithAnd = [];
+
+
+    /**
+     * SleekDB constructor.
+     * Initialize the database.
+     * @param string $dataDir
+     * @param array $configurations
+     * @throws IOException
+     * @throws InvalidConfigurationException
+     */
+    function __construct( $dataDir = '', $configurations = [] ) {
+      // Add data dir.
+      $configurations[ 'data_directory' ] = $dataDir;
+      // Initialize SleekDB
+      $this->init( $configurations );
+    }
+
+    /**
+     * Initialize the store.
+     * @param string $storeName
+     * @param string $dataDir
+     * @param array $options
+     * @return SleekDB
+     * @throws EmptyStoreNameException
+     * @throws IOException
+     * @throws InvalidConfigurationException
+     */
+    public static function store( $storeName, $dataDir, $options = [] ) {
+      if ( empty( $storeName ) ) throw new EmptyStoreNameException( 'Store name was not valid' );
+      $_dbInstance = new SleekDB( $dataDir, $options );
+      $_dbInstance->storeName = $storeName;
+      // Boot store.
+      $_dbInstance->bootStore();
+      // Initialize variables for the store.
+      $_dbInstance->initVariables();
+      return $_dbInstance;
+    }
+
+    /**
+     * Read store objects.
+     * @return array
+     * @throws ConditionNotAllowedException
+     * @throws IndexNotFoundException
+     * @throws EmptyFieldNameException
+     * @throws InvalidDataException
+     */
+    public function fetch() {
+      $fetchedData = null;
+      // Check if data should be provided from the cache.
+      if ( $this->makeCache === true ) {
+        $fetchedData = $this->reGenerateCache(); // Re-generate cache.
+      }
+      else if ( $this->useCache === true ) {
+        $fetchedData = $this->useExistingCache(); // Use existing cache else re-generate.
+      }
+      else {
+        $fetchedData = $this->findStoreDocuments(); // Returns data without looking for cached data.
+      }
+      $this->initVariables(); // Reset state.
+      return $fetchedData;
+    }
+
+    /**
+     * Creates a new object in the store.
+     * The object is a plaintext JSON document.
+     * @param array $storeData
+     * @return array
+     * @throws EmptyStoreDataException
+     * @throws IOException
+     * @throws InvalidStoreDataException
+     * @throws JsonException
+     * @throws IdNotAllowedException
+     */
+    public function insert( $storeData ) {
+      // Handle invalid data
+      if ( empty( $storeData ) ) throw new EmptyStoreDataException( 'No data found to store' );
+      // Make sure that the data is an array
+      if ( ! is_array( $storeData ) ) throw new InvalidStoreDataException( 'Storable data must an array' );
+      $storeData = $this->writeInStore( $storeData );
+      // Check do we need to wipe the cache for this store.
+      if ( $this->deleteCacheOnCreate === true ) $this->_emptyAllCache();
+      return $storeData;
+    }
+
+    /**
+     * Creates multiple objects in the store.
+     * @param $storeData
+     * @return array
+     * @throws EmptyStoreDataException
+     * @throws IOException
+     * @throws InvalidStoreDataException
+     * @throws JsonException
+     * @throws IdNotAllowedException
+     */
+    public function insertMany( $storeData ) {
+      // Handle invalid data
+      if ( empty( $storeData ) ) throw new EmptyStoreDataException( 'No data found to insert in the store' );
+      // Make sure that the data is an array
+      if ( ! is_array( $storeData ) ) throw new InvalidStoreDataException( 'Data must be an array in order to insert in the store' );
+      // All results.
+      $results = [];
+      foreach ( $storeData as $key => $node ) {
+        $results[] = $this->writeInStore( $node );
+      }
+      // Check do we need to wipe the cache for this store.
+      if ( $this->deleteCacheOnCreate === true ) $this->_emptyAllCache();
+      return $results;
+    }
+
+    /**
+     * @param $updatable
+     * @return bool
+     * @throws IndexNotFoundException
+     * @throws ConditionNotAllowedException
+     * @throws EmptyFieldNameException
+     * @throws InvalidDataException
+     */
+    public function update($updatable ) {
+      // Find all store objects.
+      $storeObjects = $this->findStoreDocuments();
+      // If no store object found then return an empty array.
+      if ( empty( $storeObjects ) ) {
+        $this->initVariables(); // Reset state.
+        return false;
+      }
+      foreach ( $storeObjects as $data ) {
+        foreach ($updatable as $key => $value ) {
+          // Do not update the _id reserved index of a store.
+          if( $key != '_id' ) {
+            $data[ $key ] = $value;
+          }
+        }
+        $storePath = $this->storePath . 'data/' . $data[ '_id' ] . '.json';
+        if ( file_exists( $storePath ) ) {
+          // Wait until it's unlocked, then update data.
+          file_put_contents( $storePath, json_encode( $data ), LOCK_EX );
+        }
+      }
+      // Check do we need to wipe the cache for this store.
+      if ( $this->deleteCacheOnCreate === true ) $this->_emptyAllCache();
+      $this->initVariables(); // Reset state.
+      return true;
+    }
+
+    /**
+     * Deletes matched store objects.
+     * @return bool
+     * @throws IOException
+     * @throws IndexNotFoundException
+     * @throws ConditionNotAllowedException
+     * @throws EmptyFieldNameException
+     * @throws InvalidDataException
+     */
+    public function delete() {
+      // Find all store objects.
+      $storeObjects = $this->findStoreDocuments();
+      if ( ! empty( $storeObjects ) ) {
+        foreach ( $storeObjects as $data ) {
+          if ( ! unlink( $this->storePath . 'data/' . $data[ '_id' ] . '.json' ) ) {
+            $this->initVariables(); // Reset state.
+            throw new IOException(
+              'Unable to delete storage file! 
+              Location: "'.$this->storePath . 'data/' . $data[ '_id' ] . '.json'.'"' 
+            );
+          }
+        }
+        // Check do we need to wipe the cache for this store.
+        if ( $this->deleteCacheOnCreate === true ) $this->_emptyAllCache();
+        $this->initVariables(); // Reset state.
+        return true;
+      } else {
+        // Nothing found to delete
+        $this->initVariables(); // Reset state.
+        return true;
+        // throw new \Exception( 'Invalid store object found, nothing to delete.' );
+      }
+    }
+
+    /**
+     * Deletes a store and wipes all the data and cache it contains.
+     * @return bool
+     */
+    public function deleteStore() {
+      $it = new \RecursiveDirectoryIterator( $this->storePath, \RecursiveDirectoryIterator::SKIP_DOTS );
+      $files = new \RecursiveIteratorIterator( $it, \RecursiveIteratorIterator::CHILD_FIRST );
+      foreach( $files as $file ) {
+        if ( $file->isDir() ) rmdir( $file->getRealPath() );
+        else unlink( $file->getRealPath() );
+      }
+      return rmdir( $this->storePath );
+    }
+
+  }

+ 101 - 0
backend/SleekDB/Traits/CacheTrait.php

@@ -0,0 +1,101 @@
+<?php
+
+  namespace SleekDB\Traits;
+
+  /**
+   * Methods required to perform the cache mechanishm.
+   */
+  trait CacheTrait {
+    
+    /**
+     * Make cache deletes the old cache if exists then creates a new cache file.
+     * returns the data.
+     * @return array
+     */
+    private function reGenerateCache() {
+      $token  = $this->getCacheToken();
+      $result = $this->findStoreDocuments();
+      // Write the cache file.
+      file_put_contents( $this->getCachePath( $token ), json_encode( $result ) );
+      // Reset cache flags to avoid future queries on the same object of the store.
+      $this->resetCacheFlags();
+      // Return the data.
+      return $result;
+    }
+
+    /**
+     * Use cache will first check if the cache exists, then re-use it.
+     * If cache dosent exists then call makeCache and return the data.
+     * @return array
+     */
+    private function useExistingCache() {
+      $token = $this->getCacheToken();
+      // Check if cache file exists.
+      if ( file_exists( $this->getCachePath( $token ) ) ) {
+        // Reset cache flags to avoid future queries on the same object of the store.
+        $this->resetCacheFlags();
+        // Return data from the found cache file.
+        return json_decode( file_get_contents( $this->getCachePath( $token ) ), true );
+      } else {
+        // Cache file was not found, re-generate the cache and return the data.
+        return $this->reGenerateCache();
+      }
+    }
+
+    /**
+     * This method would make a unique token for the current query.
+     * We would use this hash token as the id/name of the cache file.
+     * @return string
+     */
+    private function getCacheToken() {
+      $query = json_encode( [
+        'store' => $this->storePath,
+        'limit' => $this->limit,
+        'skip' => $this->skip,
+        'conditions' => $this->conditions,
+        'orConditions' => $this->orConditions,
+        'in' => $this->in,
+        'notIn' => $this->notIn,
+        'order' => $this->orderBy,
+        'search' => $this->searchKeyword,
+        'fieldsToSelect' => $this->fieldsToSelect,
+        'fieldsToExclude' => $this->fieldsToExclude,
+        'orConditionsWithAnd' => $this->orConditionsWithAnd,
+      ] );
+      return md5( $query );
+    }
+
+    /**
+     * Reset the cache flags so the next database query dosent messedup.
+     */
+    private function resetCacheFlags() {
+      $this->makeCache = false;
+      $this->useCache  = false;
+    }
+
+    /**
+     * Returns the cache directory absolute path for the current store.
+     * @param string $token
+     * @return string
+     */
+    private function getCachePath( $token ) {
+      return $this->storePath . 'cache/' . $token . '.json';
+    }
+
+    /**
+     * Delete a single cache file for current query.
+     */
+    private function _deleteCache() {
+      $token = $this->getCacheToken();
+      unlink( $this->getCachePath( $token ) );
+    }
+
+    /**
+     * Delete all cache for current store.
+     */
+    private function _emptyAllCache() {
+      array_map( 'unlink', glob( $this->storePath . "cache/*" ) );
+    }
+
+  }
+  

+ 311 - 0
backend/SleekDB/Traits/ConditionTrait.php

@@ -0,0 +1,311 @@
+<?php
+
+namespace SleekDB\Traits;
+
+use SleekDB\Exceptions\EmptyConditionException;
+use SleekDB\Exceptions\EmptyFieldNameException;
+use SleekDB\Exceptions\InvalidArgumentException;
+use SleekDB\Exceptions\InvalidOrderException;
+
+/**
+   * Coditions trait.
+   */
+  trait ConditionTrait {
+
+    /**
+     * Select specific fields or exclude fields with - (minus) prepended
+     * @param string[] $fieldNames
+     * @return $this
+     * @throws InvalidArgumentException
+     */
+    public function select($fieldNames){
+      $errorMsg = "if select is used an array containing strings with fieldNames has to be given";
+      if(!is_array($fieldNames)) throw new InvalidArgumentException($errorMsg);
+      foreach ($fieldNames as $fieldName){
+        if(empty($fieldName)) continue;
+        if(!is_string($fieldName)) throw new InvalidArgumentException($errorMsg);
+        $this->fieldsToSelect[] = $fieldName;
+      }
+      return $this;
+    }
+
+    /**
+     * @param string[] $fieldNames
+     * @return $this
+     * @throws InvalidArgumentException
+     */
+    public function except($fieldNames){
+      $errorMsg = "if except is used an array containing strings with fieldNames has to be given";
+      if(!is_array($fieldNames)) throw new InvalidArgumentException($errorMsg);
+      foreach ($fieldNames as $fieldName){
+        if(empty($fieldName)) continue;
+        if(!is_string($fieldName)) throw new InvalidArgumentException($errorMsg);
+        $this->fieldsToExclude[] = $fieldName;
+      }
+      return $this;
+    }
+
+    /**
+     * Add conditions to filter data.
+     * @param string $fieldName
+     * @param string $condition
+     * @param mixed $value
+     * @return $this
+     * @throws EmptyConditionException
+     * @throws EmptyFieldNameException
+     */
+    public function where( $fieldName, $condition, $value ) {
+      if ( empty( $fieldName ) ) throw new EmptyFieldNameException( 'Field name in where condition can not be empty.' );
+      if ( empty( $condition ) ) throw new EmptyConditionException( 'The comparison operator can not be empty.' );
+      // Append the condition into the conditions variable.
+      $this->conditions[] = [
+        'fieldName' => $fieldName,
+        'condition' => trim( $condition ),
+        'value'     => $value
+      ];
+      return $this;
+    }
+
+
+    /**
+     * @param string $fieldName
+     * @param array $values
+     * @return $this
+     * @throws EmptyFieldNameException
+     */
+    public function in ( $fieldName, $values = [] ) {
+      if ( empty( $fieldName ) ) throw new EmptyFieldNameException( 'Field name for in clause can not be empty.' );
+      $values = (array) $values;
+      $this->in[] = [
+        'fieldName' => $fieldName,
+        'value'     => $values
+      ];
+      return $this;
+    }
+
+    /**
+     * @param string $fieldName
+     * @param array $values
+     * @return $this
+     * @throws EmptyFieldNameException
+     */
+    public function notIn ( $fieldName, $values = [] ) {
+      if ( empty( $fieldName ) ) throw new EmptyFieldNameException( 'Field name for notIn clause can not be empty.' );
+      $values = (array) $values;
+      $this->notIn[] = [
+        'fieldName' => $fieldName,
+        'value'     => $values
+      ];
+      return $this;
+    }
+
+    /**
+     * Add or-where conditions to filter data.
+     * @param string|array|mixed $condition,... (string fieldName, string condition, mixed value) OR ([string fieldName, string condition, mixed value],...)
+     * @return $this
+     * @throws EmptyConditionException
+     * @throws EmptyFieldNameException
+     * @throws InvalidArgumentException
+     */
+    public function orWhere( $condition ) {
+      $args = func_get_args();
+      foreach ($args as $key => $arg){
+        if($key > 0) throw new InvalidArgumentException("Allowed: (string fieldName, string condition, mixed value) OR ([string fieldName, string condition, mixed value],...)");
+        if(is_array($arg)){
+          // parameters given as arrays for an "or where" with "and" between each condition
+          $this->orWhereWithAnd($args);
+          break;
+        }
+        if(count($args) === 3 && is_string($arg) && is_string($args[1])){
+          // parameters given as (string fieldName, string condition, mixed value) for a single "or where"
+          $this->singleOrWhere($arg, $args[1], $args[2]);
+          break;
+        }
+      }
+
+      return $this;
+    }
+
+    /**
+     * Add or-where conditions to filter data.
+     * @param string $fieldName
+     * @param string $condition
+     * @param mixed $value
+     * @return $this
+     * @throws EmptyConditionException
+     * @throws EmptyFieldNameException
+     */
+    private function singleOrWhere( $fieldName, $condition, $value ) {
+      if ( empty( $fieldName ) ) throw new EmptyFieldNameException( 'Field name in orWhere condition can not be empty.' );
+      if ( empty( $condition ) ) throw new EmptyConditionException( 'The comparison operator can not be empty.' );
+      // Append the condition into the orConditions variable.
+      $this->orConditions[] = [
+        'fieldName' => $fieldName,
+        'condition' => trim( $condition ),
+        'value'     => $value
+      ];
+      return $this;
+    }
+
+    /**
+     * @param array $conditions
+     * @return $this
+     * @throws EmptyConditionException
+     * @throws InvalidArgumentException
+     */
+    private function orWhereWithAnd($conditions){
+
+      if(!(count($conditions) > 0)){
+        throw new EmptyConditionException("You need to specify a where clause");
+      }
+
+      foreach ($conditions as $key => $condition){
+
+        if(!is_array($condition)){
+          throw new InvalidArgumentException("The where clause has to be an array");
+        }
+
+        // the user can pass the conditions as an array or a map
+        if(count($condition) === 3 && array_key_exists(0, $condition) && array_key_exists(1, $condition)
+          && array_key_exists(2, $condition)){
+
+          // user passed the condition as an array
+
+          $this->orConditionsWithAnd[] = [
+            "fieldName" => $condition[0],
+            "condition" => trim($condition[1]),
+            "value" => $condition[2]
+          ];
+        } else {
+
+          // user passed the condition as a map
+
+          if(!array_key_exists("fieldName", $condition) || empty($condition["fieldName"])){
+            throw new InvalidArgumentException("fieldName is required in where clause");
+          }
+          if(!array_key_exists("condition", $condition) || empty($condition["condition"])){
+            throw new InvalidArgumentException("condition is required in where clause");
+          }
+          if(!array_key_exists("value", $condition)){
+            throw new InvalidArgumentException("value is required in where clause");
+          }
+
+          $this->orConditionsWithAnd[] = [
+            "fieldName" => $condition["fieldName"],
+            "condition" => trim($condition["condition"]),
+            "value" => $condition["value"]
+          ];
+
+        }
+      }
+
+      return $this;
+
+    }
+
+    /**
+     * Set the amount of data record to skip.
+     * @param int $skip
+     * @return $this
+     */
+    public function skip( $skip = 0 ) {
+      if ( $skip === false ) $skip = 0;
+      $this->skip = (int) $skip;
+      return $this;
+    }
+
+    /**
+     * Set the amount of data record to limit.
+     * @param int $limit
+     * @return $this
+     */
+    public function limit( $limit = 0 ) {
+      if ( $limit === false ) $limit = 0;
+      $this->limit = (int) $limit;
+      return $this;
+    }
+
+    /**
+     * Set the sort order.
+     * @param string $order "asc" or "desc"
+     * @param string $orderBy
+     * @return $this
+     * @throws InvalidOrderException
+     */
+    public function orderBy( $order, $orderBy = '_id' ) {
+      // Validate order.
+      $order = strtolower( $order );
+      if ( ! in_array( $order, [ 'asc', 'desc' ] ) ) throw new InvalidOrderException( 'Invalid order found, please use "asc" or "desc" only.' );
+      $this->orderBy = [
+        'order' => $order,
+        'field' => $orderBy
+      ];
+      return $this;
+    }
+
+    /**
+     * Do a fulltext like search against more than one field.
+     * @param string|array $field one fieldName or multiple fieldNames as an array
+     * @param string $keyword
+     * @return $this
+     * @throws EmptyFieldNameException
+     */
+    public function search( $field, $keyword) {
+      if ( empty( $field ) ) throw new EmptyFieldNameException( 'Cant perform search due to no field name was provided' );
+      if ( ! empty( $keyword ) ) $this->searchKeyword = [
+        'field'   => (array) $field,
+        'keyword' => $keyword
+      ];
+      return $this;
+    }
+
+    /**
+     * Re-generate the cache for the query.
+     * @return $this
+     */
+    public function makeCache() {
+      $this->makeCache = true;
+      $this->useCache  = false;
+      return $this;
+    }
+
+    /**
+     * Re-use existing cache of the query, if doesnt exists
+     * then would make new cache.
+     * @return $this
+     */
+    public function useCache() {
+      $this->useCache  = true;
+      $this->makeCache = false;
+      return $this;
+    }
+
+    /**
+     * Delete cache for the current query.
+     * @return $this
+     */
+    public function deleteCache() {
+      $this->_deleteCache();
+      return $this;
+    }
+
+    /**
+     * Delete all cache of the current store.
+     * @return $this
+     */
+    public function deleteAllCache() {
+      $this->_emptyAllCache();
+      return $this;
+    }
+
+    /**
+     * Keep the active query conditions.
+     * @return $this
+     */
+    public function keepConditions () {
+      $this->shouldKeepConditions = true;
+      return $this;
+    }
+
+  }
+  

+ 590 - 0
backend/SleekDB/Traits/HelperTrait.php

@@ -0,0 +1,590 @@
+<?php
+
+  namespace SleekDB\Traits;
+
+  use SleekDB\Exceptions\ConditionNotAllowedException;
+  use SleekDB\Exceptions\IdNotAllowedException;
+  use SleekDB\Exceptions\IOException;
+  use SleekDB\Exceptions\InvalidConfigurationException;
+  use SleekDB\Exceptions\EmptyStoreNameException;
+  use SleekDB\Exceptions\IndexNotFoundException;
+  use SleekDB\Exceptions\JsonException;
+  use SleekDB\Exceptions\EmptyFieldNameException;
+  use SleekDB\Exceptions\InvalidDataException;
+
+
+  /**
+   * Collections of method that helps to manage the data.
+   * All methods in this trait should be private.
+   *
+   */
+  trait HelperTrait {
+
+    /**
+     * @param array $conf
+     * @throws IOException
+     * @throws InvalidConfigurationException
+     */
+    private function init( $conf ) {
+      // Check for valid configurations.
+      if( empty( $conf ) OR !is_array( $conf ) ) throw new InvalidConfigurationException( 'Invalid configurations was found.' );
+      // Check if the 'data_directory' was provided.
+      if ( !isset( $conf[ 'data_directory' ] ) ) throw new InvalidConfigurationException( '"data_directory" was not provided in the configurations.' );
+      // Check if data_directory is empty.
+      if ( empty( $conf[ 'data_directory' ] ) ) throw new InvalidConfigurationException( '"data_directory" cant be empty in the configurations.' );
+      // Prepare the data directory.
+      $dataDir = trim( $conf[ 'data_directory' ] );
+      // Handle directory path ending.
+      if ( substr( $dataDir, -1 ) !== '/' ) $dataDir = $dataDir . '/';
+      // Check if the data_directory exists.
+      if ( !file_exists( $dataDir ) ) {
+        // The directory was not found, create one.
+        if ( !mkdir( $dataDir, 0777, true ) ) throw new IOException( 'Unable to create the data directory at ' . $dataDir );
+      }
+      // Check if PHP has write permission in that directory.
+      if ( !is_writable( $dataDir ) ) throw new IOException( 'Data directory is not writable at "' . $dataDir . '." Please change data directory permission.' );
+      // Finally check if the directory is readable by PHP.
+      if ( !is_readable( $dataDir ) ) throw new IOException( 'Data directory is not readable at "' . $dataDir . '." Please change data directory permission.' );
+      // Set the data directory.
+      $this->dataDirectory = $dataDir;
+      // Set auto cache settings.
+      $autoCache = true;
+      if ( isset( $conf[ 'auto_cache' ] ) ) $autoCache = $conf[ 'auto_cache' ];
+      $this->initAutoCache( $autoCache );
+      // Set timeout.
+      $timeout = 120;
+      if ( isset( $conf[ 'timeout' ] ) ) {
+        if ( !empty( $conf[ 'timeout' ] ) ) $timeout = (int) $conf[ 'timeout' ];
+      }
+      set_time_limit( $timeout );
+      // Control when to keep or delete the active query conditions. Delete conditions by default.
+      $this->shouldKeepConditions = false; 
+    } // End of init()
+
+    /**
+     * Init data that SleekDB required to operate.
+     */
+    private function initVariables() {
+      if(!$this->shouldKeepConditions) {
+        // Set empty results
+        $this->results = [];
+        // Set a default limit
+        $this->limit = 0;
+        // Set a default skip
+        $this->skip = 0;
+        // Set default conditions
+        $this->conditions = [];
+        // Or conditions
+        $this->orConditions = [];
+        // In clause conditions
+        $this->in = [];
+        // notIn clause conditions 
+        $this->notIn = [];
+        // Set default group by value
+        $this->orderBy = [
+          'order' => false,
+          'field' => '_id'
+        ];
+        // Set the default search keyword as an empty string.
+        $this->searchKeyword = '';
+        // Disable make cache by default.
+        $this->makeCache = false;
+        // Control when to keep or delete the active query conditions. Delete conditions by default.
+        $this->shouldKeepConditions = false;
+        // specific fields to select
+        $this->fieldsToSelect = [];
+        $this->fieldsToExclude = [];
+
+        $this->orConditionsWithAnd = [];
+      }
+    } // End of initVariables()
+
+    /**
+     * Initialize the auto cache settings.
+     * @param bool $autoCache
+     */
+    private function initAutoCache ( $autoCache = true ) {
+      // Decide the cache status.
+      if ( $autoCache === true ) {
+        $this->useCache = true;
+        // A flag that is used to check if cache should be empty
+        // while create a new object in a store.
+        $this->deleteCacheOnCreate = true;
+      } else {
+        $this->useCache = false;
+        // A flag that is used to check if cache should be empty 
+        // while create a new object in a store.
+        $this->deleteCacheOnCreate = false;
+      }
+    }
+
+    /**
+     * Method to boot a store.
+     * @throws EmptyStoreNameException
+     * @throws IOException
+     */
+    private function bootStore() {
+      $store = trim( $this->storeName );
+      // Validate the store name.
+      if ( !$store || empty( $store ) ) throw new EmptyStoreNameException( 'Invalid store name was found' );
+      // Prepare store name.
+      if ( substr( $store, -1 ) !== '/' ) $store = $store . '/';
+      // Store directory path.
+      $this->storePath = $this->dataDirectory . $store;
+      // Check if the store exists.
+      if ( !file_exists( $this->storePath ) ) {
+        // The directory was not found, create one with cache directory.
+        if ( !mkdir( $this->storePath, 0777, true ) ) throw new IOException( 'Unable to create the store path at ' . $this->storePath );
+        // Create the cache directory.
+        if ( !mkdir( $this->storePath . 'cache', 0777, true ) ) throw new IOException( 'Unable to create the store\'s cache directory at ' . $this->storePath . 'cache' );
+        // Create the data directory.
+        if ( !mkdir( $this->storePath . 'data', 0777, true ) ) throw new IOException( 'Unable to create the store\'s data directory at ' . $this->storePath . 'data' );
+        // Create the store counter file.
+        if ( !file_put_contents( $this->storePath . '_cnt.sdb', '0' ) ) throw new IOException( 'Unable to create the system counter for the store! Please check write permission' );
+      }
+      // Check if PHP has write permission in that directory.
+      if ( !is_writable( $this->storePath ) ) throw new IOException( 'Store path is not writable at "' . $this->storePath . '." Please change store path permission.' );
+      // Finally check if the directory is readable by PHP.
+      if ( !is_readable( $this->storePath ) ) throw new IOException( 'Store path is not readable at "' . $this->storePath . '." Please change store path permission.' );
+    }
+
+    // Returns a new and unique store object ID, by calling this method it would also
+    // increment the ID system-wide only for the store.
+    private function getStoreId() {
+      $counter = 1; // default (first) id
+      $counterPath = $this->storePath . '_cnt.sdb';
+      if ( file_exists( $counterPath ) ) {
+        $fp = fopen($counterPath, 'r+');
+        for($retries = 10; $retries > 0; $retries--) {
+          flock($fp, LOCK_UN);
+          if (flock($fp, LOCK_EX) === false) {
+            sleep(1);
+          } else {
+            $counter = (int) fgets($fp);
+            $counter++;
+            rewind($fp);
+            fwrite($fp, (string) $counter);
+            break;
+          }
+        }
+        flock($fp, LOCK_UN);
+        fclose($fp);
+      }
+      return $counter;
+    }
+
+    /**
+     * Return the last created store object ID.
+     * @return int
+     */
+    private function getLastStoreId() {
+      $counterPath = $this->storePath . '_cnt.sdb';
+      if ( file_exists( $counterPath ) ) {
+        return (int) file_get_contents( $counterPath );
+      }
+      return 0;
+    }
+
+    /**
+     * Get a store by its system id. "_id"
+     * @param $id
+     * @return array|mixed
+     */
+    private function getStoreDocumentById( $id ) {
+      $store = $this->storePath . 'data/' . $id . '.json';
+      if ( file_exists( $store ) ) {
+        $data = json_decode( file_get_contents( $store ), true );
+        if ( $data !== false ) return $data;
+      }
+      return [];
+    }
+
+    /**
+     * @param string $file
+     * @return mixed
+     */
+    private function getDocumentByPath ( $file ) {
+      return @json_decode( @file_get_contents( $file ), true );
+    }
+
+    /**
+     * @param string $condition
+     * @param mixed $fieldValue value of current field
+     * @param mixed $value value to check
+     * @throws ConditionNotAllowedException
+     * @return bool
+     */
+    private function verifyWhereConditions ( $condition, $fieldValue, $value ) {
+      // Check the type of rule.
+      if ( $condition === '=' ) {
+        // Check equal.
+        return ( $fieldValue == $value );
+      } else if ( $condition === '!=' ) {
+        // Check not equal.
+        return ( $fieldValue != $value );
+      } else if ( $condition === '>' ) {
+        // Check greater than.
+        return ( $fieldValue > $value );
+      } else if ( $condition === '>=' ) {
+        // Check greater equal.
+        return ( $fieldValue >= $value );
+      } else if ( $condition === '<' ) {
+        // Check less than.
+        return ( $fieldValue < $value );
+      } else if ( $condition === '<=' ) {
+        // Check less equal.
+        return ( $fieldValue <= $value );
+      } else if (strtolower($condition) === 'like'){
+          $value = str_replace('%', '(.)*', $value);
+          $pattern = "/^".$value."$/i";
+          return (preg_match($pattern, $fieldValue) === 1);
+      }
+      throw new ConditionNotAllowedException('condition '.$condition.' is not allowed');
+    }
+
+    /**
+     * @return array
+     * @throws IndexNotFoundException
+     * @throws ConditionNotAllowedException
+     * @throws EmptyFieldNameException
+     * @throws InvalidDataException
+     */
+    private function findStoreDocuments() {
+      $found = [];
+      // Start collecting and filtering data.
+      $storeDataPath = $this->storePath . 'data/';
+      if( $handle = opendir($storeDataPath) ) {
+        while ( false !== ($entry = readdir($handle)) ) {
+          if ($entry != "." && $entry != "..") {
+            $file = $storeDataPath . $entry;
+            $data = $this->getDocumentByPath( $file );
+            $document = false;
+            if ( ! empty( $data ) ) {
+              // Filter data found.
+              if ( empty( $this->conditions ) ) {
+                // Append all data of this store.
+                $document = $data;
+              } else {
+                // Append only passed data from this store.
+                $storePassed = true;
+                // Iterate each conditions.
+                foreach ( $this->conditions as $condition ) {
+                  if ( $storePassed === true ) {
+                    // Check for valid data from data source.
+                    $validData = true;
+                    $fieldValue = '';
+                    try {
+                      $fieldValue = $this->getNestedProperty( $condition[ 'fieldName' ], $data );
+                    } catch( \Exception $e ) {
+                      $validData   = false;
+                      $storePassed = false;
+                    }
+                    if( $validData === true ) {
+                      $storePassed = $this->verifyWhereConditions( $condition[ 'condition' ], $fieldValue, $condition[ 'value' ] );
+                    }
+                  }
+                }
+                // Check if current store is updatable or not.
+                if ( $storePassed === true ) {
+                  // Append data to the found array.
+                  $document = $data;
+                } else {
+                  // Check if a or-where condition will allow this document.
+                  foreach ( $this->orConditions as $condition ) {
+                    // Check for valid data from data source.
+                    $validData = true;
+                    $fieldValue = '';
+                    try {
+                      $fieldValue = $this->getNestedProperty( $condition[ 'fieldName' ], $data );
+                    } catch( \Exception $e ) {
+                      $validData   = false;
+                      $storePassed = false;
+                    }
+                    if( $validData === true ) {
+                      $storePassed = $this->verifyWhereConditions( $condition[ 'condition' ], $fieldValue, $condition[ 'value' ] );
+                      if( $storePassed ) {
+                        // Append data to the found array.
+                        $document = $data;
+                        break;
+                      }
+                    }
+                  }
+                }
+                // Check if current store is updatable or not.
+                if ( $storePassed === true ) {
+                  // Append data to the found array.
+                  $document = $data;
+                } else if(count($this->orConditionsWithAnd) > 0) {
+                  // Check if a all conditions will allow this document.
+                  $allConditionMatched = true;
+                  foreach ( $this->orConditionsWithAnd as $condition ) {
+                    // Check for valid data from data source.
+                    $validData = true;
+                    $fieldValue = '';
+                    try {
+                      $fieldValue = $this->getNestedProperty( $condition[ 'fieldName' ], $data );
+                    } catch( \Exception $e ) {
+                      $validData   = false;
+                    }
+                    if( $validData === true ) {
+                      $storePassed = $this->verifyWhereConditions( $condition[ 'condition' ], $fieldValue, $condition[ 'value' ] );
+                      if($storePassed) continue;
+                    }
+
+                    // if data was invalid or store did not pass
+                    $allConditionMatched = false;
+                    break;
+                  }
+                  if( $allConditionMatched === true ) {
+                    // Append data to the found array.
+                    $document = $data;
+                  }
+                }
+              } // Completed condition checks.
+    
+              // IN clause.
+              if( $document && !empty($this->in) ) {
+                foreach ( $this->in as $inClause) {
+                  $validData = true;
+                  $fieldValue = '';
+                  try {
+                    $fieldValue = $this->getNestedProperty( $inClause[ 'fieldName' ], $data );
+                  } catch( \Exception $e ) {
+                    $validData = false;
+                    $document = false;
+                    break;
+                  }
+                  if( $validData === true ) {
+                    if( !in_array( $fieldValue, $inClause[ 'value' ] ) ) {
+                      $document = false;
+                      break;
+                    }
+                  }
+                }
+              }
+    
+              // notIn clause.
+              if ( $document && !empty($this->notIn) ) {
+                foreach ( $this->notIn as $notInClause) {
+                  $validData = true;
+                  $fieldValue = '';
+                  try {
+                    $fieldValue = $this->getNestedProperty( $notInClause[ 'fieldName' ], $data );
+                  } catch( \Exception $e ) {
+                    $validData = false;
+                    break;
+                  }
+                  if( $validData === true ) {
+                    if( in_array( $fieldValue, $notInClause[ 'value' ] ) ) {
+                      $document = false;
+                      break;
+                    }
+                  }
+                }
+              }
+    
+              // Check if there is any document appendable.
+              if( $document ) {
+                $found[] = $document;
+              }
+            }
+          }
+        }
+        closedir( $handle );
+      }
+
+      if ( count( $found ) > 0 ) {
+        // Check do we need to sort the data.
+        if ( $this->orderBy[ 'order' ] !== false ) {
+          // Start sorting on all data.
+          $found = $this->sortArray( $this->orderBy[ 'field' ], $found, $this->orderBy[ 'order' ] );
+        }
+        // If there was text search then we would also sort the result by search ranking.
+        if ( ! empty( $this->searchKeyword ) ) {
+          $found = $this->performSearch( $found );
+        }
+        // Skip data
+        if ( $this->skip > 0 ) $found = array_slice( $found, $this->skip );
+        // Limit data.
+        if ( $this->limit > 0 ) $found = array_slice( $found, 0, $this->limit );
+      }
+
+      if(count($found) > 0){
+        if(count($this->fieldsToSelect) > 0){
+          $found = $this->applyFieldsToSelect($found);
+        }
+        if(count($this->fieldsToExclude) > 0){
+          $found = $this->applyFieldsToExclude($found);
+        }
+      }
+
+      return $found;
+    }
+
+    /**
+     * @param array $found
+     * @return array
+     */
+    private function applyFieldsToSelect($found){
+      if(!(count($found) > 0) || !(count($this->fieldsToSelect) > 0)){
+        return $found;
+      }
+      foreach ($found as $key => $item){
+        $newItem = [];
+        $newItem['_id'] = $item['_id'];
+        foreach ($this->fieldsToSelect as $fieldToSelect){
+          if(array_key_exists($fieldToSelect, $item)){
+            $newItem[$fieldToSelect] = $item[$fieldToSelect];
+          }
+        }
+        $found[$key] = $newItem;
+      }
+      return $found;
+    }
+
+    /**
+     * @param array $found
+     * @return array
+     */
+    private function applyFieldsToExclude($found){
+      if(!(count($found) > 0) || !(count($this->fieldsToExclude) > 0)){
+        return $found;
+      }
+      foreach ($found as $key => $item){
+        foreach ($this->fieldsToExclude as $fieldToExclude){
+          if(array_key_exists($fieldToExclude, $item)){
+            unset($item[$fieldToExclude]);
+          }
+        }
+        $found[$key] = $item;
+      }
+      return $found;
+    }
+
+
+    /**
+     * Writes an object in a store.
+     * @param $storeData
+     * @return array
+     * @throws IOException
+     * @throws JsonException
+     * @throws IdNotAllowedException
+     */
+    private function writeInStore( $storeData ) {
+      // Cast to array
+      $storeData = (array) $storeData;
+      // Check if it has _id key
+      if ( isset( $storeData[ '_id' ] ) ) throw new IdNotAllowedException( 'The _id index is reserved by SleekDB, please delete the _id key and try again' );
+      $id = $this->getStoreId();
+      // Add the system ID with the store data array.
+      $storeData[ '_id' ] = $id;
+      // Prepare storable data
+      $storableJSON = json_encode( $storeData );
+      if ( $storableJSON === false ) throw new JsonException( 'Unable to encode the data array, 
+        please provide a valid PHP associative array' );
+      // Define the store path
+      $storePath = $this->storePath . 'data/' . $id . '.json';
+      if ( ! file_put_contents( $storePath, $storableJSON ) ) {
+        throw new IOException( "Unable to write the object file! Please check if PHP has write permission." );
+      }
+      return $storeData;
+    }
+
+    /**
+     * Sort store objects.
+     * @param $field
+     * @param $data
+     * @param string $order
+     * @return array
+     * @throws IndexNotFoundException
+     * @throws EmptyFieldNameException
+     * @throws InvalidDataException
+     */
+    private function sortArray( $field, $data, $order = 'ASC' ) {
+      $dryData = [];
+      // Check if data is an array.
+      if( is_array( $data ) ) {
+        // Get value of the target field.
+        foreach ( $data as $value ) {
+          $dryData[] = $this->getNestedProperty( $field, $value );
+        }
+      }
+      // Descide the order direction.
+      if ( strtolower( $order ) === 'asc' ) asort( $dryData );
+      else if ( strtolower( $order ) === 'desc' ) arsort( $dryData );
+      // Re arrange the array.
+      $finalArray = [];
+      foreach ( $dryData as $key => $value) {
+        $finalArray[] = $data[ $key ];
+      }
+      return $finalArray;
+    }
+
+    /**
+     * Get nested properties of a store object.
+     * @param string $fieldName
+     * @param array $data
+     * @return array|mixed
+     * @throws EmptyFieldNameException
+     * @throws IndexNotFoundException
+     * @throws InvalidDataException
+     */
+    private function getNestedProperty($fieldName, $data ) {
+      if( !is_array( $data ) ) throw new InvalidDataException('data has to be an array');
+      if(empty( $fieldName )) throw new EmptyFieldNameException('fieldName is not allowed to be empty');
+
+      // Dive deep step by step.
+      foreach(explode( '.', $fieldName ) as $i ) {
+        // If the field do not exists then insert an empty string.
+        if ( ! isset( $data[ $i ] ) ) {
+          $data = '';
+          throw new IndexNotFoundException( '"'.$i.'" index was not found in the provided data array' );
+        }
+        // The index is valid, collect the data.
+        $data = $data[ $i ];
+      }
+      return $data;
+    }
+
+    /**
+     * Do a search in store objects. This is like a doing a full-text search.
+     * @param array $data
+     * @return array
+     */
+    private function performSearch($data = [] ) {
+      if ( empty( $data ) ) return $data;
+      $nodesRank = [];
+      // Looping on each store data.
+      foreach ($data as $key => $value) {
+        // Looping on each field name of search-able fields.
+        foreach ($this->searchKeyword[ 'field' ] as $field) {
+          try {
+            $nodeValue = $this->getNestedProperty( $field, $value );
+            // The searchable field was found, do comparison against search keyword.
+            similar_text( strtolower($nodeValue), strtolower($this->searchKeyword['keyword']), $perc );
+            if ( $perc > 50 ) {
+              // Check if current store object already has a value, if so then add the new value.
+              if ( isset( $nodesRank[ $key ] ) ) $nodesRank[ $key ] += $perc;
+              else $nodesRank[ $key ] = $perc;
+            }
+          } catch ( \Exception $e ) {
+            continue;
+          }
+        }
+      }
+      if ( empty( $nodesRank ) ) {
+        // No matched store was found against the search keyword.
+        return [];
+      }
+      // Sort nodes in descending order by the rank.
+      arsort( $nodesRank );
+      // Map original nodes by the rank.
+      $nodes = [];
+      foreach ( $nodesRank as $key => $value ) {
+        $nodes[] = $data[ $key ];
+      }
+      return $nodes;
+    }
+    
+  }
+  

+ 6 - 0
backend/config.php

@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * 最多保存多少条测试记录
+ */
+const MAX_LOG_COUNT = 100;

+ 10 - 14
backend/getIP.php

@@ -100,7 +100,8 @@ function getIpInfoTokenString()
  */
 function getIspInfo($ip)
 {
-    $json = file_get_contents('https://ipinfo.io/'.$ip.'/json'.getIpInfoTokenString());
+//    $json = file_get_contents('https://ipinfo.io/'.$ip.'/json'.getIpInfoTokenString());
+    $json = file_get_contents('https://api.ip.sb/geoip/' . $ip);
     if (!is_string($json)) {
         return null;
     }
@@ -122,15 +123,15 @@ function getIsp($rawIspInfo)
 {
     if (
         !is_array($rawIspInfo)
-        || !array_key_exists('org', $rawIspInfo)
-        || !is_string($rawIspInfo['org'])
-        || empty($rawIspInfo['org'])
+        || !array_key_exists('organization', $rawIspInfo)
+        || !is_string($rawIspInfo['organization'])
+        || empty($rawIspInfo['organization'])
     ) {
-        return 'Unknown ISP';
+        return 'Unknown';
     }
 
     // Remove AS##### from ISP name, if present
-    return preg_replace('/AS\\d+\\s/', '', $rawIspInfo['org']);
+    return $rawIspInfo['organization'];
 }
 
 /**
@@ -294,7 +295,6 @@ function sendHeaders()
 function sendResponse(
     $ip,
     $ipInfo = null,
-    $distance = null,
     $rawIspInfo = null
 ) {
     $processedString = $ip;
@@ -306,10 +306,7 @@ function sendResponse(
         is_array($rawIspInfo)
         && array_key_exists('country', $rawIspInfo)
     ) {
-        $processedString .= ', '.$rawIspInfo['country'];
-    }
-    if (is_string($distance)) {
-        $processedString .= ' ('.$distance.')';
+        $processedString .= ' - '.$rawIspInfo['country'] . ',' . $rawIspInfo['region'] . ',' . $rawIspInfo['city'];
     }
 
     sendHeaders();
@@ -320,7 +317,6 @@ function sendResponse(
 }
 
 $ip = getClientIp();
-
 $localIpInfo = getLocalOrPrivateIpInfo($ip);
 // local ip, no need to fetch further information
 if (is_string($localIpInfo)) {
@@ -335,6 +331,6 @@ if (!isset($_GET['isp'])) {
 
 $rawIspInfo = getIspInfo($ip);
 $isp = getIsp($rawIspInfo);
-$distance = getDistance($rawIspInfo);
+//$distance = getDistance($rawIspInfo);
 
-sendResponse($ip, $isp, $distance, $rawIspInfo);
+sendResponse($ip, $isp, $rawIspInfo);

+ 33 - 0
backend/report.php

@@ -0,0 +1,33 @@
+<?php
+
+require_once "./SleekDB/SleekDB.php";
+require_once "./config.php";
+
+$store = \SleekDB\SleekDB::store('speedlogs', './',[
+    'auto_cache' => false,
+    'timeout' => 120
+]);
+
+$reportData = [
+    "ip" => filter_var($_POST['ip'], FILTER_SANITIZE_STRING),
+    "isp" => filter_var($_POST['isp'], FILTER_SANITIZE_STRING),
+    "addr" => filter_var($_POST['addr'], FILTER_SANITIZE_STRING),
+    "dspeed" => filter_var($_POST['dspeed'], FILTER_SANITIZE_STRING),
+    "uspeed" => filter_var($_POST['uspeed'], FILTER_SANITIZE_STRING),
+    "ping" => filter_var($_POST['ping'], FILTER_SANITIZE_STRING),
+    "jitter" => filter_var($_POST['jitter'], FILTER_SANITIZE_STRING),
+    "created" => date('Y-m-d H:i:s', time()),
+];
+
+$oldLog = $store->where('ip', '=', $reportData['ip'])->fetch();
+
+if (is_array($oldLog) && empty($oldLog)) {
+     $results = $store->insert($reportData);
+     if ($results['_id'] > MAX_LOG_COUNT) {
+         $store->where('_id', '=', $results['_id'] - MAX_LOG_COUNT)->delete();
+     }
+} else {
+    $ip = $reportData['ip'];
+    unset($reportData['ip']);
+    $store->where('ip', '=', $ip)->update($reportData);
+}

+ 21 - 0
backend/results-api.php

@@ -0,0 +1,21 @@
+<?php
+
+require_once "./SleekDB/SleekDB.php";
+require_once "./config.php";
+
+$store = \SleekDB\SleekDB::store('speedlogs', './',[
+    'auto_cache' => false,
+    'timeout' => 120
+]);
+
+$logs = $store
+    ->orderBy( 'desc', 'created' )
+    ->limit( MAX_LOG_COUNT )
+    ->fetch();
+
+$data = [
+    'code' => 0,
+    'data' => $logs,
+];
+
+echo json_encode($data);

+ 53 - 0
backend/results.html

@@ -0,0 +1,53 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>测速结果 | speedtest-x</title>
+  <meta name="renderer" content="webkit">
+  <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
+  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
+  <link rel="stylesheet" href="https://www.layuicdn.com/layui/css/layui.css"  media="all">
+  <style>
+    .footer {line-height: 30px; text-align: center;}
+    .footer a{padding:0 6px; font-weight: 300;}
+    .footer a:hover{color: green;}
+    .header {line-height: 30px; text-align: center;height:50px;padding-top:50px;}
+  </style>
+</head>
+<body>
+<div class="header">
+  <h1>speedtest-x 测速结果</h1>
+</div>
+
+<table class="layui-hide" id="test"></table>
+
+<div class="footer">
+  <a href="https://github.com/BadApple9/speedtest-x">speedtest-x 项目地址</a>
+</div>
+
+<script src="https://www.layuicdn.com/layui/layui.js" charset="utf-8"></script>
+
+<script>
+    layui.use('table', function(){
+        var table = layui.table;
+
+        table.render({
+            elem: '#test'
+            ,url:'/backend/results-api.php'
+            ,cellMinWidth: 80
+            ,cols: [[
+                {field:'ip', title: 'IP地址'}
+                ,{field:'isp', title: '运营商'}
+                ,{field:'addr', title: '城市'}
+                ,{field:'dspeed', title: '下载速度 (Mbps)', sort: true, align: 'right'}
+                ,{field:'uspeed', title: '上传速度 (Mbps)', sort: true, align: 'right'}
+                ,{field:'ping', title: 'Ping (ms)', sort: true, align: 'right'}
+                ,{field:'jitter', title: '抖动 (ms)', sort: true, align: 'right'}
+                ,{field:'created', title: '测试时间', sort: true}
+            ]]
+        });
+    });
+</script>
+
+</body>
+</html>

+ 16 - 2
index.html

@@ -10,6 +10,8 @@
 
 //INITIALIZE SPEEDTEST
 var s=new Speedtest(); //create speedtest object
+var xhr=new XMLHttpRequest();
+var url_report='backend/report.php';
 s.onupdate=function(data){ //callback to update data in UI
     I("ip").textContent=data.clientIp;
     I("dlText").textContent=(data.testState==1&&data.dlStatus==0)?"...":data.dlStatus;
@@ -17,7 +19,19 @@ s.onupdate=function(data){ //callback to update data in UI
     I("pingText").textContent=data.pingStatus;
     I("jitText").textContent=data.jitterStatus;
     var prog=(Number(data.dlProgress)*2+Number(data.ulProgress)*2+Number(data.pingProgress))/5;
-    I("progress").style.width=(100*prog)+"%";
+	I("progress").style.width=(100*prog)+"%";
+	var ipIspArr = I("ip").textContent.split(' - ', 3);
+	var ip = ipIspArr[0];
+	var isp = ipIspArr[1];
+	var addr = ipIspArr[2] === undefined? '' :ipIspArr[2];
+	var progress = Math.floor(100*prog);
+	if (progress > 20 && (progress % 10 == 0)) {
+		var params = 'ip='+ip+'&isp='+isp+'&addr='+addr+'&dspeed='+I("dlText").textContent+'&uspeed='+I("ulText").textContent+'&ping='+I("pingText").textContent
+						+'&jitter='+I("jitText").textContent;
+		xhr.open('POST', url_report, true);
+		xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
+		xhr.send(params);
+	}
 }
 s.onend=function(aborted){ //callback for test ended/aborted
     I("startStopBtn").className=""; //show start button again
@@ -204,7 +218,7 @@ function I(id){return document.getElementById(id);}
 		IP Address: <span id="ip"></span>
 	</div>
 </div>
-<a href="https://github.com/BadApple9/speedtest-x">Speedtext-X Source code</a>
+<a href="https://github.com/BadApple9/speedtest-x">speedtest-x 项目地址</a>
 <script type="text/javascript">
     initUI();
 </script>